Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<workspace>/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.
Expand Down
19 changes: 7 additions & 12 deletions apps/web/src/components/ChatMarkdown.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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) {
Expand Down
49 changes: 20 additions & 29 deletions apps/web/src/components/ChatView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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) => {
Expand Down
21 changes: 16 additions & 5 deletions apps/web/src/components/RightPanelTabs.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -193,7 +195,8 @@ const SURFACE_UNAVAILABLE_HINTS = {

type TabContextMenuAction =
| "rename"
| "copy-path"
| "copy-relative-path"
| "copy-full-path"
| "toggle-mute"
| "close"
| "close-others"
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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": {
Expand Down
17 changes: 16 additions & 1 deletion apps/web/src/components/chat/OpenInPicker.test.ts
Original file line number Diff line number Diff line change
@@ -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],
Expand Down
Loading