Repository navigation
feat(web): attach a folder from the composer's attach button on the local environment #33261
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
659867e
dea492c
44c2e31
1fe49e6
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -49,6 +49,7 @@ import { | |
| } from "@t3tools/contracts"; | ||
| import type { EnvironmentConnectionPresentation } from "@t3tools/client-runtime/connection"; | ||
| import { settlePromise, squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime"; | ||
| import * as Option from "effect/Option"; | ||
| import * as Schema from "effect/Schema"; | ||
| import { formatComposerModelMention } from "@t3tools/client-runtime/composer-model-mentions"; | ||
| import { isPasteAsTextShortcut } from "@t3tools/client-runtime/text-paste"; | ||
|
|
@@ -264,7 +265,11 @@ import { Kbd } from "../ui/kbd"; | |
| import { useSoftwareKeyboardOpen } from "../../hooks/useSoftwareKeyboardOpen"; | ||
| import { useStableCallback } from "../../hooks/useStableCallback"; | ||
| import { usePanelAnimationSettings } from "../../panelAnimations"; | ||
| import { readEnvironmentScope, readPreparedConnection } from "../../state/session"; | ||
| import { | ||
| readEnvironmentScope, | ||
| readPreparedConnection, | ||
| usePreparedConnection, | ||
| } from "../../state/session"; | ||
| import type { ReviewCommentContext } from "../../reviewCommentContext"; | ||
| import { useComposerRestingTransition } from "./useComposerRestingTransition"; | ||
| import { ComposerCommandMenuLayer } from "./ComposerCommandMenuLayer"; | ||
|
|
@@ -2668,6 +2673,41 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) | |
| }; | ||
| const insertComposerTextAtEndStable = useStableCallback(insertComposerTextAtEnd); | ||
|
|
||
| const readPrimaryRunsInWsl = () => | ||
| window.desktopBridge | ||
| ?.getLocalEnvironmentBootstraps() | ||
| .some( | ||
| (bootstrap) => | ||
| bootstrap.id === PRIMARY_LOCAL_ENVIRONMENT_ID && | ||
| typeof bootstrap.runningDistro === "string", | ||
| ) === true; | ||
| // A synchronous IPC read on desktop; the setting only changes with a restart, so read it once. | ||
| const [localEnvironmentDisabledAtMount] = useState(isLocalEnvironmentDisabled); | ||
| // Also a synchronous IPC read; only the picker's visibility uses this snapshot, drops re-read it. | ||
| const [primaryRunsInWslAtMount] = useState(readPrimaryRunsInWsl); | ||
| const preparedConnection = usePreparedConnection(environmentId); | ||
| const resolveFolderTarget = () => | ||
| folderDropTarget({ | ||
| localEnvironmentDisabled: localEnvironmentDisabledAtMount, | ||
| environmentId, | ||
| primaryEnvironmentId, | ||
| connection: readPreparedConnection(environmentId), | ||
| isDesktopRenderer: window.desktopBridge !== undefined, | ||
| primaryRunsInWsl: readPrimaryRunsInWsl(), | ||
| }); | ||
| const insertFolderPathLink = (path: string): boolean => { | ||
| const inserted = insertComposerTextAtEndStable(`${serializeComposerFileLink(path)} `, { | ||
| ensureLeadingBoundary: true, | ||
| }); | ||
| if (!inserted) { | ||
| toastManager.add({ | ||
| type: "error", | ||
| title: "Unable to add folder", | ||
| description: "The composer is busy; try again once it is ready.", | ||
| }); | ||
| } | ||
| return inserted; | ||
| }; | ||
| const insertComposerFolders = async ( | ||
| folders: File[], | ||
| target: FolderDropTarget, | ||
|
|
@@ -2692,37 +2732,13 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) | |
| }); | ||
| continue; | ||
| } | ||
| const inserted = insertComposerTextAtEndStable(`${serializeComposerFileLink(path)} `, { | ||
| ensureLeadingBoundary: true, | ||
| }); | ||
| if (!inserted) { | ||
| toastManager.add({ | ||
| type: "error", | ||
| title: "Unable to add folder", | ||
| description: "The composer is busy; try again once it is ready.", | ||
| }); | ||
| break; | ||
| } | ||
| if (!insertFolderPathLink(path)) break; | ||
| } | ||
| focusComposer(); | ||
| }; | ||
|
|
||
| const addComposerFolders = (folders: File[]) => { | ||
| const target = folderDropTarget({ | ||
| localEnvironmentDisabled: isLocalEnvironmentDisabled(), | ||
| environmentId, | ||
| primaryEnvironmentId, | ||
| connection: readPreparedConnection(environmentId), | ||
| isDesktopRenderer: window.desktopBridge !== undefined, | ||
| primaryRunsInWsl: | ||
| window.desktopBridge | ||
| ?.getLocalEnvironmentBootstraps() | ||
| .some( | ||
| (bootstrap) => | ||
| bootstrap.id === PRIMARY_LOCAL_ENVIRONMENT_ID && | ||
| typeof bootstrap.runningDistro === "string", | ||
| ) === true, | ||
| }); | ||
| const target = resolveFolderTarget(); | ||
| if (target === "remote") { | ||
| toastManager.add({ | ||
| type: "error", | ||
|
|
@@ -2738,6 +2754,34 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) | |
| ); | ||
| }; | ||
| const addComposerFoldersStable = useStableCallback(addComposerFolders); | ||
| // The desktop folder dialog returns a path on this computer, so it is only offered when the | ||
| // thread's server shares this filesystem, the same rule as dropped folders. | ||
| const pickFolder = window.desktopBridge?.pickFolder; | ||
| // The dialog refuses to open while the local environment is off, even for a loopback server. | ||
| const canPickLocalFolder = | ||
| pickFolder !== undefined && | ||
| !localEnvironmentDisabledAtMount && | ||
| folderDropTarget({ | ||
| localEnvironmentDisabled: localEnvironmentDisabledAtMount, | ||
| environmentId, | ||
| primaryEnvironmentId, | ||
| connection: Option.getOrNull(preparedConnection), | ||
| isDesktopRenderer: true, | ||
| primaryRunsInWsl: primaryRunsInWslAtMount, | ||
| }) === "local"; | ||
| const attachLocalFolder = async () => { | ||
| if (!pickFolder) return; | ||
| let path: string | null; | ||
| try { | ||
| path = await pickFolder(gitCwd ? { initialPath: gitCwd } : undefined); | ||
| } catch { | ||
| toastManager.add({ type: "error", title: "Couldn't open the folder picker" }); | ||
| return; | ||
| } | ||
| if (path !== null) insertFolderPathLink(path); | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Ignore the folder-picker result when the draft or composer changes during the await.
Capture the draft key before the await. After the await, return if the composer is unmounted or the key has changed. Suggested fix
|
||
| focusComposer(); | ||
| }; | ||
| const attachLocalFolderStable = useStableCallback(attachLocalFolder); | ||
|
|
||
| const onComposerPaste = (event: React.ClipboardEvent<HTMLElement>) => { | ||
| const { files, folders } = splitTransferredFiles(event.clipboardData); | ||
|
|
@@ -3323,6 +3367,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) | |
| <ComposerAttachButton | ||
| addComposerAttachments={addComposerAttachments} | ||
| focusComposer={focusComposer} | ||
| attachFolder={canPickLocalFolder ? attachLocalFolderStable : undefined} | ||
| /> | ||
| ) : null; | ||
| return ( | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,21 @@ | ||
| import { expect, it, vi } from "vite-plus/test"; | ||
| import { page, userEvent } from "vite-plus/test/browser"; | ||
|
|
||
| import { render } from "../../../test/render"; | ||
| import { ComposerAttachButton } from "./ComposerAttachmentStrip"; | ||
|
|
||
| it("opens the Files and Folder menu with a mouse click and picks a folder", async () => { | ||
| const attachFolder = vi.fn(); | ||
| await using _screen = await render( | ||
| <ComposerAttachButton | ||
| addComposerAttachments={async () => true} | ||
| focusComposer={() => {}} | ||
| attachFolder={attachFolder} | ||
| />, | ||
| ); | ||
|
|
||
| await userEvent.click(page.getByRole("button", { name: "Attach files or a folder" })); | ||
| await userEvent.click(page.getByRole("menuitem", { name: "Folder…" })); | ||
|
|
||
| expect(attachFolder).toHaveBeenCalledOnce(); | ||
| }); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🟠 High
chat/ChatComposer.tsx:2639With
localEnvironmentDisabledAtMounttrue and a saved loopback server,canPickLocalFolderis true and exposes Folder, butpickFolderreturnsnullbefore opening the dialog, so the action silently does nothing. Gate the picker on the same setting, or update the IPC handler to allow saved local servers.🤖 Copy this AI Prompt to have your agent fix this:
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Fixed in dea492c: Folder… is now hidden while the local environment is off, since the desktop dialog refuses to open then (dropped folders on a loopback server still get a path link). The same commit reads the mount-time setting in
resolveFolderTargettoo (CodeRabbit nitpick).There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Sorry, I'm unable to act on this request because you do not have permissions within this repository.