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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ This is a personal fork of [pingdotgg/t3code](https://github.com/pingdotgg/t3cod

- **Indefinite snooze** — an "Until I wake it" preset in the thread sidebar snooze menus parks a thread on the snoozed shelf with no timer: it comes back only when explicitly woken or when you send it a message. Indefinite rows sort after every timed wake and carry a "parked" label; the T3 CLI reports them as `Snoozed: until woken`.
- **Conversation forking** — fork a Codex or Claude thread mid-conversation into a new thread, including immediately after importing an external session; forked threads are titled with a 🔱 prefix.
- **Two-pane split view** — web and desktop can show a second thread beside the routed one, including a thread from a different environment. Open it from the titlebar button or the ⌘K "Open thread in split view..." action; the secondary pane carries its own switch and close controls, the URL and sidebar keep driving only the main pane, and opening the secondary thread in the main pane folds the split. A ring marks the active pane, a rebindable shortcut (default mod+\) jumps between panes, and window shortcuts — including permission-prompt number keys — act only on the active pane. While split, each pane's right panel opens as an overlay sheet, terminals stay with the pane that owns their thread, the divider drags and remembers its position, and actions that create a new thread (new thread, implement plan) stay in the main pane.
- **Two-pane split view** — web and desktop can show a second thread beside the routed one, including a thread from a different environment. Open it from the chat header button (between the git actions and the terminal toggle) or the ⌘K "Open thread in split view..." action. While split, hovering the divider reveals its controls near the top: swap the two threads (also ⌘K "Swap split threads", rebindable, default mod+shift+\), switch the right pane's thread, and close the split. Sidebar clicks and ⌘K thread picks open into the active pane — picking a thread already on screen just focuses its pane, so neither path can accidentally fold the split — and the sidebar marks both open threads with left/right pane glyphs. A ring marks the active pane, a rebindable shortcut (default mod+\) jumps between panes, and window shortcuts — including permission-prompt number keys — act only on the active pane. Each pane's right panel opens as an overlay sheet, terminals stay with the pane that owns their thread, the divider drags and remembers its position, and actions that create a new thread (new thread, implement plan) stay in the main pane.
- **Mobile swipe-right actions** — swiping a thread row right reveals Pin (Unpin on a pinned row), Fork, and Archive, in that order. Each action only appears where it applies — pin needs a server that supports pinning, fork needs a forkable Codex or Claude conversation — and every one of them has a twin in the row's long-press menu. Pin and Archive keep you on the list; Fork opens the new copy. Archive stays innermost, so a full swipe right still archives.
- **Session import** — import external Claude Code and Codex CLI sessions as native T3 threads from thread-sidebar project settings (choosing the environment when the project is grouped across several) or the desktop project context menu, including strict resume/continuation, Claude-generated and explicit CLI session titles, and explicit instance labels when more than one provider instance can import sessions. Sessions already owned by a T3 thread stay visible as linked candidates; importing one again forks the provider history into a fresh continuation so the original thread keeps sole ownership of its native session.
- **Archived-thread recents, search, grouping & undo** — the final thread-sidebar section on desktop and mobile keeps a configurable recent archive close at hand, with one-action restore, archive-aware menus, and automatic restore when a new message is sent; the full archive remains searchable, grouped, and filterable by project. The open web thread can be archived from the command palette or a rebindable shortcut (default mod+shift+E), a mobile thread row archives with a full swipe right, and on web Cmd+Z outside the composer reverses the most recent archive or snooze, whichever happened last — an empty new-thread screen reopens the restored thread, while another active conversation stays in place. The section folds on every client and starts folded, since archived threads are the ones you deliberately put away; the header then reports the archive count, and the thread you have open keeps its row while the section is folded.
Expand Down
2 changes: 0 additions & 2 deletions apps/web/src/components/ChatView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -393,7 +393,6 @@ import type { ThreadSyncPhase } from "../threadSync";
import { useLocalStorage } from "~/hooks/useLocalStorage";
import { useComposerHandleContext } from "../composerHandleContext";
import { useThreadPaneId } from "./thread-split/threadPaneContext";
import { ThreadPaneControls } from "./thread-split/ThreadPaneControls";
import { isThreadPaneActive, useThreadSplitStore } from "./thread-split/threadSplitStore";
import { sanitizeThreadErrorMessage } from "~/rpc/transportError";
import { RightPanelSheet } from "./RightPanelSheet";
Expand Down Expand Up @@ -7125,7 +7124,6 @@ function ChatViewContent(props: ChatViewProps) {
onToggle={toggleRightPanelMaximized}
/>
) : null}
<ThreadPaneControls />
{panelToggleControls}
</div>
);
Expand Down
57 changes: 54 additions & 3 deletions apps/web/src/components/CommandPalette.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ import {
ArrowUpToLineIcon,
CircleCheckIcon,
CircleDotIcon,
ArrowLeftRightIcon,
Columns2Icon,
CopyIcon,
CornerLeftUpIcon,
Expand Down Expand Up @@ -198,10 +199,15 @@ import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip";
import { ComposerHandleContext, useComposerHandleContext } from "../composerHandleContext";
import type { ChatComposerHandle } from "./chat/ChatComposer";
import { buildOpenInSplitThreadItems } from "./thread-split/splitPaletteItems";
import { swapThreadPanes } from "./thread-split/swapThreadPanes";
import { openThreadInActivePane } from "./thread-split/threadOpenTarget";
import {
activeThreadPaneComposerHandle,
capturePaletteOwnerPane,
focusActiveThreadPane,
focusOtherThreadPane,
paletteOwnerPane,
requestThreadPaneFocus,
THREAD_SPLIT_MEDIA_QUERY,
useThreadSplitStore,
} from "./thread-split/threadSplitStore";
Expand Down Expand Up @@ -566,6 +572,14 @@ export function CommandPalette({ children }: { children: ReactNode }) {
[openAddProject, openInSplit, openNewThreadIn, setOpen],
);

// Thread picks must target the pane that owned focus when the palette
// OPENED. The dialog's own focus traffic (list focus, close-time restores)
// moves the live active pane while it is up, so run callbacks read this
// snapshot instead.
useEffect(() => {
if (state.open) capturePaletteOwnerPane();
}, [state.open]);

return (
<ComposerHandleContext value={composerHandleRef}>
<CommandDialog
Expand Down Expand Up @@ -1295,14 +1309,30 @@ function OpenCommandPaletteDialog(props: {
: undefined;
},
runThread: async (thread) => {
await navigate({
to: "/$environmentId/$threadId",
params: buildThreadRouteParams(scopeThreadRef(thread.environmentId, thread.id)),
const targetRef = scopeThreadRef(thread.environmentId, thread.id);
const { plan, completion } = openThreadInActivePane({
targetRef,
routeThreadRef,
paneOverride: paletteOwnerPane(),
navigateToPrimary: () =>
navigate({
to: "/$environmentId/$threadId",
params: buildThreadRouteParams(targetRef),
}),
});
if (plan.kind === "open-secondary") {
// The closing palette restores focus to its trigger a beat later;
// the intent bounces that restore into the pane just opened.
requestThreadPaneFocus("secondary");
}
// Awaited so a navigation rejection still reaches the palette's
// run-command error handling, as it did before the split routing.
if (completion) await completion;
},
}),
[
activeThreadKey,
routeThreadRef,
clientSettings.sidebarThreadSortOrder,
navigate,
projectCwdByKey,
Expand All @@ -1317,6 +1347,7 @@ function OpenCommandPaletteDialog(props: {
const recentThreadItems = allThreadItems.slice(0, RECENT_THREAD_LIMIT);

const splitSecondaryRef = useThreadSplitStore((state) => state.secondaryRef);
const splitMounted = useThreadSplitStore((state) => state.splitMounted);
const splitSupported = useMediaQuery(THREAD_SPLIT_MEDIA_QUERY);
const openInSplitItems = useMemo(
() =>
Expand Down Expand Up @@ -1930,6 +1961,26 @@ function OpenCommandPaletteDialog(props: {
},
});
}
if (splitSupported && splitMounted && splitSecondaryRef !== null && routeThreadRef !== null) {
actionItems.push({
kind: "action",
value: "action:swap-split-threads",
searchTerms: ["split", "swap", "pane", "exchange", "flip", "trade"],
title: "Swap split threads",
icon: <ArrowLeftRightIcon className={ITEM_ICON_CLASS} />,
shortcutCommand: "threadPane.swap",
run: async () => {
await swapThreadPanes({
routeThreadRef,
navigateToThread: (ref) =>
navigate({
to: "/$environmentId/$threadId",
params: buildThreadRouteParams(ref),
}),
});
},
});
}
// Not gated on viewport width: a split opened on a wide window must stay
// closable after the window shrinks below the split breakpoint.
if (splitSecondaryRef !== null) {
Expand Down
66 changes: 60 additions & 6 deletions apps/web/src/components/Sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,12 @@ import {
import { formatCompactRelativeTimeLabel } from "../timestampFormat";
import type { SidebarThreadSummary } from "../types";
import { buildThreadActionMenuItems } from "./threadActionMenu.logic";
import { openThreadInActivePane } from "./thread-split/threadOpenTarget";
import {
SplitPaneMarkerIcon,
useSplitSecondaryThreadKey,
type SplitPaneMarker,
} from "./thread-split/SplitPaneMarker";
import { SidebarEnvironmentFilterMenu } from "./sidebar/SidebarEnvironmentFilter";
import { resolveSidebarEmptyStateCause } from "./sidebar/sidebarEmptyState";
import { useSidebarEnvironmentFilter } from "./sidebar/useSidebarEnvironmentFilter";
Expand Down Expand Up @@ -831,6 +837,8 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {
// the user visits the thread.
wokeAt: string | null;
isActive: boolean;
// Which split pane shows this thread, while the split view is on screen.
splitPaneMarker: SplitPaneMarker | null;
openPullRequestsInRightPanel: boolean;
jumpLabel: string | null;
currentEnvironmentId: string | null;
Expand Down Expand Up @@ -1351,9 +1359,15 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {
<TerminalIcon className={cn("size-3.5", terminalStatus.pulse && "animate-status-pulse")} />
</span>
) : null;
// Only the two threads on screen ever get a non-null marker, so the list
// stays quiet.
const splitPaneIcon = props.splitPaneMarker ? (
<SplitPaneMarkerIcon marker={props.splitPaneMarker} />
) : null;
const diff = latestTurnDiff(thread);
const cardTrailingMetadata = !isRenaming ? (
<>
{splitPaneIcon}
{/* Upstream anchors the worktree marker to the branch, which compact
cards do not render. Carrying it here covers both card shapes instead
of only the expanded one. Slim rows (settled, snoozed) build their own
Expand Down Expand Up @@ -1443,6 +1457,10 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {
visibility={props.providerIconVisibility}
/>
) : null}
{/* A settled or snoozed thread can still occupy a split pane, so
the marker rides slim rows too — like the terminal and PR
icons already do. */}
{splitPaneIcon}
{terminalStatusIcon}
{isRegeneratingTitle ? (
<span role="status" className="sr-only">
Expand Down Expand Up @@ -2167,6 +2185,7 @@ export default function Sidebar() {
[routeDraftThread, routeTarget],
);
const routeThreadKey = routeThreadRef ? scopedThreadKey(routeThreadRef) : null;
const splitSecondaryKey = useSplitSecondaryThreadKey();
// Post-settle navigation validates against the CURRENT route, not the one
// captured when the settle started: if the user navigated elsewhere while
// the command was in flight, completing it must not yank them away.
Expand Down Expand Up @@ -3066,6 +3085,32 @@ export default function Sidebar() {
},
[clearSelection, isMobile, router, setOpenMobile, setSelectionAnchor],
);
// Direct picks (row clicks, Enter/Space, search results, archived rows)
// route through the split-aware helper so the thread lands in the active
// pane; forward navigation after settle/archive keeps using
// navigateToThread — it must always retarget the primary route. The
// primary branch delegates to navigateToThread wholesale, so only the
// split-local branches repeat its selection/mobile prep. The sidebar sits
// outside both pane roots, so the click's own pointerdown never changes
// which pane is active.
const openThreadFromSidebar = useCallback(
(threadRef: ScopedThreadRef) => {
const { plan } = openThreadInActivePane({
targetRef: threadRef,
routeThreadRef,
navigateToPrimary: () => navigateToThread(threadRef),
});
if (plan.kind === "navigate-primary") return;
if (useThreadSelectionStore.getState().selectedThreadKeys.size > 0) {
clearSelection();
}
setSelectionAnchor(scopedThreadKey(threadRef));
if (isMobile) {
setOpenMobile(false);
}
},
[clearSelection, isMobile, navigateToThread, routeThreadRef, setOpenMobile, setSelectionAnchor],
);
const attemptUnarchive = useCallback(
(threadRef: ScopedThreadRef) => {
void (async () => {
Expand Down Expand Up @@ -3144,9 +3189,9 @@ export default function Sidebar() {
const selectThreadSearchResult = useCallback(
(thread: EnvironmentThreadShell) => {
clearThreadSearch();
navigateToThread(scopeThreadRef(thread.environmentId, thread.id));
openThreadFromSidebar(scopeThreadRef(thread.environmentId, thread.id));
},
[clearThreadSearch, navigateToThread],
[clearThreadSearch, openThreadFromSidebar],
);
const handleThreadSearchKeyDown = useCallback(
(event: ReactKeyboardEvent<HTMLInputElement>) => {
Expand Down Expand Up @@ -3242,9 +3287,9 @@ export default function Sidebar() {
if (isTrailingDoubleClick(event.detail)) {
return;
}
navigateToThread(threadRef);
openThreadFromSidebar(threadRef);
},
[navigateToThread, rangeSelectTo, toggleThreadSelection],
[openThreadFromSidebar, rangeSelectTo, toggleThreadSelection],
);

const attemptArchive = useCallback(
Expand Down Expand Up @@ -4692,6 +4737,15 @@ export default function Sidebar() {
// rows resolve to null on their own.
wokeAt={threadWokeAt(thread, { now: snoozeNow })}
isActive={routeThreadKey === threadKey}
splitPaneMarker={
splitSecondaryKey === null
? null
: threadKey === splitSecondaryKey
? "right"
: threadKey === routeThreadKey
? "left"
: null
}
openPullRequestsInRightPanel={routeThreadRef !== null}
jumpLabel={showJumpHints ? (jumpLabelByKey.get(threadKey) ?? null) : null}
currentEnvironmentId={primaryEnvironmentId}
Expand Down Expand Up @@ -4723,7 +4777,7 @@ export default function Sidebar() {
}
timestampFormat={timestampFormat}
onThreadClick={handleThreadClick}
onThreadActivate={navigateToThread}
onThreadActivate={openThreadFromSidebar}
onStartRename={startThreadRename}
onRenameTitleChange={setRenamingTitle}
onCommitRename={commitThreadRename}
Expand Down Expand Up @@ -5023,7 +5077,7 @@ export default function Sidebar() {
routeThreadKey ===
scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id))
}
onOpen={navigateToThread}
onOpen={openThreadFromSidebar}
onUnarchive={attemptUnarchive}
onContextMenu={handleArchivedThreadContextMenu}
/>
Expand Down
2 changes: 2 additions & 0 deletions apps/web/src/components/chat/ChatHeader.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ import ProjectScriptsControl, {
type ProjectScriptActionResult,
} from "../ProjectScriptsControl";
import { OpenInPicker } from "./OpenInPicker";
import { OpenSplitViewControl } from "../thread-split/PaneControls";
import { useRemoteOpenState, type RemoteOpenMode } from "../../remoteOpen";
import { usePrimaryEnvironmentId } from "../../state/environments";
import { useT3ProjectFileScripts } from "~/hooks/useT3ProjectFileScripts";
Expand Down Expand Up @@ -408,6 +409,7 @@ export const ChatHeader = memo(function ChatHeader({
{...(draftId ? { draftId } : {})}
/>
)}
<OpenSplitViewControl />
</div>
</div>
);
Expand Down
68 changes: 68 additions & 0 deletions apps/web/src/components/thread-split/PaneControls.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import { Columns2Icon } from "lucide-react";

import { Button } from "../ui/button";
import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
import { openCommandPalette } from "../../commandPaletteBus";
import { useMediaQuery } from "../../hooks/useMediaQuery";
import { useThreadPaneId } from "./threadPaneContext";
import { THREAD_SPLIT_MEDIA_QUERY, useThreadSplitStore } from "./threadSplitStore";

/**
* "Open split view" for the chat header's action row. Fully self-gated so
* the ChatHeader seam stays a bare render: nothing shows in the secondary
* pane, while a split is already open (its controls live on the divider —
* see SplitPaneControls), or on viewports too narrow for a split.
*/
export function OpenSplitViewControl() {
const paneId = useThreadPaneId();
const splitActive = useThreadSplitStore((state) => state.secondaryRef !== null);
const isWideEnoughForSplit = useMediaQuery(THREAD_SPLIT_MEDIA_QUERY);

if (paneId === "secondary" || splitActive || !isWideEnoughForSplit) {
return null;
}

return (
<PaneControlButton
label="Open split view"
onClick={() => openCommandPalette({ open: "open-in-split" })}
>
<Columns2Icon className="size-4" />
</PaneControlButton>
);
}

export function PaneControlButton({
children,
label,
onClick,
disabled = false,
tooltipSide = "bottom",
}: {
children: React.ReactNode;
label: string;
onClick: () => void;
disabled?: boolean;
tooltipSide?: "top" | "bottom" | "left" | "right";
}) {
return (
<Tooltip>
{/* The span, not the button, triggers the tooltip: a disabled button
receives no pointer events, and its label is exactly the state that
needs explaining (same pattern as PanelLayoutControls). */}
<TooltipTrigger render={<span className="flex shrink-0" />}>
<Button
className="shrink-0 [-webkit-app-region:no-drag]"
aria-label={label}
variant="ghost"
size="sm"
disabled={disabled}
onClick={onClick}
>
{children}
</Button>
</TooltipTrigger>
<TooltipPopup side={tooltipSide}>{label}</TooltipPopup>
</Tooltip>
);
}
Loading