diff --git a/README.md b/README.md index fee5d4c40541..97a52bdcacfa 100644 --- a/README.md +++ b/README.md @@ -47,6 +47,7 @@ This is a personal fork of [pingdotgg/t3code](https://github.com/pingdotgg/t3cod - **Message queueing and steering** — on web and mobile, sending during a running turn steers at the agent's next step after a recall window; that window defaults to five seconds, is configurable on web (Settings → Extras) and per device on mobile (Settings → Thread behavior), and can be retired early from the visible outbox. On web, pressing Enter again in the empty composer also sends the newest steering message immediately. A message that waits instead — the “Queue for later” send option, or anything that piled up while the thread was unreachable — holds until the turn completes, and then the whole waiting queue is released into the next turn together rather than one message per turn. On web, ArrowUp recalls the newest queued message into an otherwise empty composer. The outbox on both clients also lets queued messages be steered, edited, or deleted; on mobile a delete confirmation holds the message so it cannot send underneath the dialog, and cancelling restores its place in the queue. On web, a queued message whose images no longer resolve offers a recovery action instead of failing silently, and an outbox that is full or unavailable says so rather than dropping the composer's contents. Once a steer is dispatched, its bubble in the timeline is dimmed and marked “Waiting for the agent to pick this up” until the main agent moves on — Claude Code only drains its prompt queue between a tool result and the next model request, so a steer sent behind a long subagent or shell call can genuinely sit unread for minutes. The marker is derived from thread state, so it shows on every device, survives reloads, and is still there when you come back to the thread. - **Per-project default model** — Project Settings can pin the model that new threads pre-select (a selection carried from a thread you're viewing still wins within the same project). Picking a model in an existing thread changes that thread only, without rewriting the project default. - **Linear issue panel** — on web and desktop, a `linear.app//issue/KEY-123` link in any message opens the issue as a tab in the right panel instead of the browser, and bare identifiers like `SP-123` do the same for the team keys you list. The tab shows the title, description, and comments, with the issue's properties as a row of chips that use Linear's own status and priority glyphs; the assignee, creator, project, and team open their Linear pages, and the parent, sub-issues, blocking and related issues, and attached links each get a section when the issue has them. Sub-issues and relations open as further tabs, the git branch name copies with one click, it refreshes on demand, and you can post a comment. Unsent comments survive tab switches within the same browser session; hovering a link shows a preview card. The personal API key (stored server-side, one per environment) and the team keys live in Settings → Extras → Linear. Mobile keeps opening these links in the system browser. +- **File path actions.** The open-in menu on a file tab ends with Reveal in Finder (File Explorer or Files on other hosts), Copy relative path, and Copy full path. Right-clicking the file name in the breadcrumb, the file tab, a file in the tree, or a diff header offers the same copy pair. Reveal, and the right-click menu's Open and Open with, only appear while you are on the machine that hosts the environment, since launching an app on another computer helps nobody. The menu's default-app entry is now called "Default app" rather than "Finder", because a file opens in whatever app owns its type. - **Composer thread commands** let you edit the current title with `/t3-name`, set a new title with `/t3-rename`, or set its status emoji with `/t3-status` on web, desktop, and mobile. `/t3-archive` archives now or after the running turn finishes, with `/t3-archive cancel` to cancel a pending request. - **Saved prompt library** — reusable prompts (title + content) managed in Settings → Prompts and synced across connected environments with whole-library last-write-wins, including catch-up for environments that were offline during an edit. `/prompt` in the composer opens a filterable picker (titles and content previews) that inserts the prompt at the cursor without sending; the command palette's **Prompts...** submenu inserts the selected prompt into the composer on Enter and copies it on Cmd+Enter on macOS or Ctrl+Enter on Windows and Linux. - **Thread naming & sidebar polish** — split thread naming with refined fork titles; the thread sidebar adds optional two-line active-thread cards, the attention filter and New project buttons on the search row with an opt-in placement for the New thread button at the end of the project filter row, per-project color accents shared across connected machines and shown in mobile thread lists (cached on-device so a cold mobile launch paints them instead of flashing them in as environments connect), accent tinting for web thread rows and new-thread project choices plus per-device mobile tinting (Settings → Appearance) — off keeps the accent as a project-header dot on both — one-click project-filter clearing, and a setting to show each thread's provider icon on hover, always, or never. On web and desktop, thread cards' hover actions now lead with a pin/unpin toggle (filled pin while pinned), the Settle button is just the checkmark, and forkable Codex and Claude conversations add a Fork button between Settle and Archive; mobile reaches pin through the swipe action and the row menu. Pinned and Active both fold behind their own headers on web and mobile. Active has an inbox icon and a stronger neutral label to distinguish it from custom groups. Unsent drafts get their own foldable Drafts section at the top of the web and desktop sidebar, shown only while a draft exists. The Drafts, Pinned, Active, Snoozed, Settled, and Archived sections all keep their fold state per device across relaunches. diff --git a/apps/web/src/components/ChatMarkdown.tsx b/apps/web/src/components/ChatMarkdown.tsx index eebc0a1f925f..0a36a2818e4a 100644 --- a/apps/web/src/components/ChatMarkdown.tsx +++ b/apps/web/src/components/ChatMarkdown.tsx @@ -107,10 +107,7 @@ import { MediaActions, type MediaActionSource } from "./media/MediaActions"; import { resolveProtocolRelativeMediaUrl } from "./media/mediaContent"; import { FileTagChipContent } from "./chat/FileTagChip"; import { PierreEntryIcon } from "./chat/PierreEntryIcon"; -import { - revealInFileExplorerLabelForKind, - revealInFileExplorerLabelForOs, -} from "./preview/fileExplorerLabel"; +import { revealInFileManagerLabel as resolveRevealInFileManagerLabel } from "../fileContextMenu"; import { resolveExternalWebLinkHost, showExternalLinkContextMenu, @@ -2342,14 +2339,12 @@ function useChatMarkdownState({ const openInEditor = useAtomCommand(shellEnvironment.openInEditor, { reportFailure: false, }); - const revealInFileManagerLabel = - environmentId !== null && - serverConfig?.shellRevealInFileManager === true && - serverConfig.availableEditors.includes("file-manager") - ? serverConfig.shellRevealInFileManagerKind === undefined - ? revealInFileExplorerLabelForOs(serverConfig.environment.platform.os) - : revealInFileExplorerLabelForKind(serverConfig.shellRevealInFileManagerKind) - : undefined; + const revealInFileManagerLabel = resolveRevealInFileManagerLabel({ + environmentId, + serverConfig, + remoteOpenMode: remoteOpen.state.mode, + remoteOpenResolved: remoteOpen.isResolved, + }); const revealFileInFileManager = useCallback( (filePath: string) => { if (environmentId === null) { diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 10aed7cb69b7..818b3ca6c9cb 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -227,7 +227,9 @@ import { RightPanelTabs } from "./RightPanelTabs"; import { AgentsPanel } from "./AgentsPanel"; import { LinkPullRequestDialogHost } from "./pullRequest/LinkPullRequestDialog"; import { ThreadPullRequestsPanel } from "./pullRequest/ThreadPullRequestsPanel"; +import { copyFilePathToClipboard, type FilePathCopyKind } from "~/fileContextMenu"; import { useDeviceState } from "~/state/device"; +import { isAbsolutePath, resolvePathLinkTarget } from "~/terminal-links"; import { DeviceSetup } from "./device/DeviceSetup"; import { Dialog } from "./ui/dialog"; import { WizardPopup } from "./ui/wizard"; @@ -5351,37 +5353,26 @@ export default function ChatView(props: ChatViewProps) { finishRightPanelSurfaceClose, rightPanelState.surfaces, ]); - const copyRightPanelFilePath = useCallback((relativePath: string) => { - if (typeof window === "undefined" || !navigator.clipboard?.writeText) { - toastManager.add( - stackedThreadToast({ - type: "error", - title: "Failed to copy path", - description: "Clipboard API unavailable.", - }), - ); - return; - } - - void navigator.clipboard.writeText(relativePath).then( - () => { + const copyRightPanelFilePath = useCallback( + (relativePath: string, kind: FilePathCopyKind) => { + // A host file's path is already full. Anything else needs the workspace + // root; joining onto an empty root would fabricate a path from `/`. + if (kind === "relative" || isAbsolutePath(relativePath)) { + void copyFilePathToClipboard(relativePath, kind); + return; + } + if (activeWorkspaceRoot === undefined) { toastManager.add({ - type: "success", - title: "Path copied", - description: relativePath, + type: "error", + title: "Failed to copy full path", + description: "The project root is not loaded yet.", }); - }, - (error) => { - toastManager.add( - stackedThreadToast({ - type: "error", - title: "Failed to copy path", - description: error instanceof Error ? error.message : "An error occurred.", - }), - ); - }, - ); - }, []); + return; + } + void copyFilePathToClipboard(resolvePathLinkTarget(relativePath, activeWorkspaceRoot), kind); + }, + [activeWorkspaceRoot], + ); useEffect( () => subscribePreviewAction((action) => { diff --git a/apps/web/src/components/RightPanelTabs.tsx b/apps/web/src/components/RightPanelTabs.tsx index 14f5cac9ccf3..d392bab7bd80 100644 --- a/apps/web/src/components/RightPanelTabs.tsx +++ b/apps/web/src/components/RightPanelTabs.tsx @@ -43,7 +43,9 @@ import { isElectron } from "~/env"; import type { DesktopPreviewOverlay } from "~/previewStateStore"; import type { RightPanelSurface } from "~/rightPanelStore"; import { cn } from "~/lib/utils"; +import { type FilePathCopyKind, filePathCopyLabel } from "~/fileContextMenu"; import { readLocalApi } from "~/localApi"; +import { isAbsolutePath } from "~/terminal-links"; import { Button } from "~/components/ui/button"; import { AndroidIcon, AppleIcon } from "~/components/Icons"; import { Tooltip, TooltipPopup, TooltipTrigger } from "~/components/ui/tooltip"; @@ -111,7 +113,7 @@ interface RightPanelTabsProps { onCloseOtherSurfaces: (surface: RightPanelSurface) => void; onCloseSurfacesToRight: (surface: RightPanelSurface) => void; onCloseAllSurfaces: () => void; - onCopyFilePath: (relativePath: string) => void; + onCopyFilePath: (relativePath: string, kind: FilePathCopyKind) => void; onAddBrowser: () => void; /** * Separate from `onAddBrowser` on purpose: that one is passed directly as a @@ -193,7 +195,8 @@ const SURFACE_UNAVAILABLE_HINTS = { type TabContextMenuAction = | "rename" - | "copy-path" + | "copy-relative-path" + | "copy-full-path" | "toggle-mute" | "close" | "close-others" @@ -969,7 +972,11 @@ export function RightPanelTabs(props: RightPanelTabsProps) { if (surface.kind === "device" && props.onRenameDevice) items.push({ id: "rename", label: "Rename" }); if (surface.kind === "file" && surface.attachment === undefined) { - items.push({ id: "copy-path", label: "Copy path" }); + // A host file's only path is the full one. + if (!isAbsolutePath(surface.relativePath)) { + items.push({ id: "copy-relative-path", label: filePathCopyLabel("relative") }); + } + items.push({ id: "copy-full-path", label: filePathCopyLabel("full") }); } const menuPreviewTabId = previewTabIdOf(surface, props.previewSessions); // Desktop overlay state only arrives once the preview manager has created @@ -1014,9 +1021,13 @@ export function RightPanelTabs(props: RightPanelTabsProps) { case "rename": setRenamingDevice(surface.id); break; - case "copy-path": + case "copy-relative-path": + case "copy-full-path": if (surface.kind === "file" && surface.attachment === undefined) { - props.onCopyFilePath(surface.relativePath); + props.onCopyFilePath( + surface.relativePath, + action === "copy-relative-path" ? "relative" : "full", + ); } break; case "toggle-mute": { diff --git a/apps/web/src/components/chat/OpenInPicker.test.ts b/apps/web/src/components/chat/OpenInPicker.test.ts index 32be49a9ba44..98b2b8730668 100644 --- a/apps/web/src/components/chat/OpenInPicker.test.ts +++ b/apps/web/src/components/chat/OpenInPicker.test.ts @@ -1,10 +1,25 @@ -import { FolderClosedIcon } from "lucide-react"; +import { AppWindowIcon, FolderClosedIcon } from "lucide-react"; import { describe, expect, it } from "vite-plus/test"; import { FileExplorerIcon, FinderIcon } from "../Icons"; import { resolveOpenInOptions } from "./OpenInPicker"; describe("resolveOpenInOptions", () => { + it.each(["MacIntel", "Win32", "Linux x86_64"] as const)( + "labels the file manager as the default app for a file target on %s", + (platform) => { + expect(resolveOpenInOptions(platform, ["vscode", "file-manager"], "file")).toEqual([ + expect.objectContaining({ value: "vscode", label: "VS Code" }), + expect.objectContaining({ + value: "file-manager", + label: "Default app", + Icon: AppWindowIcon, + kind: "generic", + }), + ]); + }, + ); + it.each([ ["MacIntel", "Finder", FinderIcon], ["Win32", "File Explorer", FileExplorerIcon], diff --git a/apps/web/src/components/chat/OpenInPicker.tsx b/apps/web/src/components/chat/OpenInPicker.tsx index c725e11e938f..8b9e4ed8464e 100644 --- a/apps/web/src/components/chat/OpenInPicker.tsx +++ b/apps/web/src/components/chat/OpenInPicker.tsx @@ -15,7 +15,13 @@ import { useRemoteOpenState, } from "../../remoteOpen"; import { useEnvironment } from "../../state/environments"; -import { ChevronDownIcon, FolderClosedIcon, SquareArrowOutUpRightIcon } from "lucide-react"; +import { + AppWindowIcon, + ChevronDownIcon, + CopyIcon, + FolderClosedIcon, + SquareArrowOutUpRightIcon, +} from "lucide-react"; import { Button } from "../ui/button"; import { Group, GroupSeparator } from "../ui/group"; import { @@ -23,6 +29,7 @@ import { MenuItem, MenuItemLabel, MenuPopup, + MenuSeparator, MenuShortcut, MenuSub, MenuSubTrigger, @@ -56,6 +63,11 @@ import { RustRoverIcon, WebStormIcon, } from "../JetBrainsIcons"; +import { + type FileContextMenuAction, + type FileContextMenuTarget, + useFileContextMenu, +} from "../../fileContextMenu"; import { cn, isMacPlatform, isWindowsPlatform } from "~/lib/utils"; import { shellEnvironment } from "~/state/shell"; import { useAtomCommand } from "~/state/use-atom-command"; @@ -69,9 +81,29 @@ type OpenInOption = { kind: "brand" | "generic"; }; +/** The file context menu's actions the picker shows; open and open-with are the editor list. */ +const FILE_PICKER_ACTIONS: ReadonlySet = new Set([ + "reveal-in-folder", + "copy-relative-path", + "copy-full-path", +]); + +const fileManagerIconForPlatform = (platform: string) => + isMacPlatform(platform) + ? FinderIcon + : isWindowsPlatform(platform) + ? FileExplorerIcon + : FolderClosedIcon; + +/** + * The file-manager editor opens its target with the OS: a folder lands in + * Finder, a file in whatever app owns its type. The option is labelled for + * what the user gets, so a file surface calls it "Default app". + */ export const resolveOpenInOptions = ( platform: string, availableEditors: ReadonlyArray, + target: "directory" | "file" = "directory", ) => { const baseOptions: ReadonlyArray> = [ { @@ -174,20 +206,24 @@ export const resolveOpenInOptions = ( value: "webstorm", kind: "brand", }, - { - Icon: isMacPlatform(platform) - ? FinderIcon - : isWindowsPlatform(platform) - ? FileExplorerIcon - : FolderClosedIcon, - value: "file-manager", - kind: isMacPlatform(platform) || isWindowsPlatform(platform) ? "brand" : "generic", - }, + target === "file" + ? { Icon: AppWindowIcon, value: "file-manager", kind: "generic" } + : { + Icon: fileManagerIconForPlatform(platform), + value: "file-manager", + kind: isMacPlatform(platform) || isWindowsPlatform(platform) ? "brand" : "generic", + }, ]; const availableEditorSet = new Set(availableEditors); return baseOptions .filter((option) => availableEditorSet.has(option.value)) - .map((option) => ({ ...option, label: editorLabelForPlatform(option.value, platform) })); + .map((option) => ({ + ...option, + label: + target === "file" && option.value === "file-manager" + ? "Default app" + : editorLabelForPlatform(option.value, platform), + })); }; function getOpenInIconClass(kind: OpenInOption["kind"]) { @@ -199,6 +235,7 @@ export const OpenInPicker = memo(function OpenInPicker({ keybindings, availableEditors, openInCwd, + fileRelativePath, presentation = "toolbar", compact = false, enableShortcut = true, @@ -207,6 +244,12 @@ export const OpenInPicker = memo(function OpenInPicker({ keybindings: ResolvedKeybindingsConfig; availableEditors: ReadonlyArray; openInCwd: string | null; + /** + * Set when `openInCwd` is a file rather than a folder: relabels the + * file-manager entry and appends reveal and copy-path actions for it. A + * string, so the memoized picker still skips renders with unchanged props. + */ + fileRelativePath?: string | undefined; presentation?: "toolbar" | "menu"; compact?: boolean; enableShortcut?: boolean; @@ -220,10 +263,23 @@ export const OpenInPicker = memo(function OpenInPicker({ // the viewing machine, which only the desktop app can probe. const effectiveEditors = remote.mode === "local-exec" ? availableEditors : remoteCapableEditors; const [preferredEditor, setPreferredEditor] = usePreferredEditor(effectiveEditors); + const isFile = fileRelativePath !== undefined; const options = useMemo( - () => resolveOpenInOptions(navigator.platform, effectiveEditors), - [effectiveEditors], + () => resolveOpenInOptions(navigator.platform, effectiveEditors, isFile ? "file" : "directory"), + [effectiveEditors, isFile], ); + // Reveal and copy act on the environment host, so they take the file's + // resolved path as is; the picker never guesses it from a workspace root. + const fileContextMenu = useFileContextMenu(isFile ? environmentId : null); + const fileTarget: FileContextMenuTarget | null = + fileRelativePath !== undefined && openInCwd + ? { + environmentId, + filePath: fileRelativePath, + workspaceRoot: undefined, + absolutePath: openInCwd, + } + : null; const primaryOption = options.find(({ value }) => value === preferredEditor) ?? null; const openInEditor = useCallback( @@ -327,6 +383,41 @@ export const OpenInPicker = memo(function OpenInPicker({ )} ); + const density = presentation === "menu" ? "touch" : "default"; + // Chosen once like `options`: the compiler lint rejects a component picked in render. + const revealIcon = useMemo(() => { + const platform = navigator.platform; + return { + Icon: fileManagerIconForPlatform(platform), + kind: isMacPlatform(platform) || isWindowsPlatform(platform) ? "brand" : "generic", + } as const; + }, []); + // The shared builder decides which file actions apply (reveal only on the + // host, no relative path for a host file); the picker only adds icons. + const fileMenuItems = fileTarget + ? fileContextMenu.buildItems(fileTarget).filter((item) => FILE_PICKER_ACTIONS.has(item.id)) + : []; + const fileItems = + fileTarget && fileMenuItems.length > 0 ? ( + <> + + {fileMenuItems.map((item) => ( + void fileContextMenu.activate(item.id, fileTarget)} + > + {item.id === "reveal-in-folder" ? ( + + ))} + + ) : null; + if (presentation === "menu") { return ( <> @@ -351,6 +442,7 @@ export const OpenInPicker = memo(function OpenInPicker({ {editorItems} + {fileItems} ); } @@ -387,7 +479,10 @@ export const OpenInPicker = memo(function OpenInPicker({ >