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 @@ -30,7 +30,7 @@ This is a personal fork of [pingdotgg/t3code](https://github.com/pingdotgg/t3cod
- **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.
- **Composer thread commands** — `/t3-rename` (prefilled with the current title, on web and mobile) and `/t3-status` to set a thread's status emoji.
- **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 copies the selected prompt on Enter and inserts it into the composer on mod+Enter when one is open.
- **Thread naming & sidebar polish** — split thread naming with refined fork titles; the thread sidebar adds optional two-line active-thread cards, manual “Move to top” actions reflected across connected clients plus an opt-in latest-user-message order (mobile has its own device-local switch for the same ordering), an opt-in placement for the New thread button beside New project, 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) and the Settle button is just the checkmark; mobile reaches pin through the swipe action and the row menu. The pinned block folds behind its own header like the other sections, on web and mobile alike, and the Pinned, Older, Snoozed, Settled, and Archived sections all keep their fold state per device across relaunches.
- **Thread naming & sidebar polish** — split thread naming with refined fork titles; the thread sidebar adds optional two-line active-thread cards, manual “Move to top” actions reflected across connected clients plus an opt-in latest-user-message order (mobile has its own device-local switch for the same ordering), an opt-in placement for the New thread button beside New project, 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. The pinned block folds behind its own header like the other sections, on web and mobile alike, and the Pinned, Older, Snoozed, Settled, and Archived sections all keep their fold state per device across relaunches.
- **Older thread section** — an opt-in foldable **Older** section (Settings → Extras → Sidebar on web and desktop, Settings → General on mobile) collects threads that have gone quiet for longer than a configurable window, seven days by default. It is a grouping, not a lifecycle state: nothing is settled, snoozed, or archived, the rows stay full cards with their usual actions, and a message or a Move to top returns a thread to the list immediately. Pinned, snoozed, and settled threads keep their own sections, live and waiting work is never folded away (a running session, background work outliving its turn, an approval or input request, an undecided plan, or a thread that just came back from a snooze), and the section steps aside entirely while the Attention filter is on. The shelf's starting fold state is a setting; folding or unfolding it by hand overrides that per device on every client, and the thread you have open keeps its row even while the section is folded. Mobile stores the section's three settings per device and offers the window as a scale of stops rather than web's free-form day count.
- **Thread actions beyond the sidebar row** — acting on the thread you're reading never means finding its row first. The chat header title (click it, or right-click the breadcrumb) opens a menu with Move to top, Fork conversation, Archive thread, and Copy thread ID, and the web command palette (⌘K) carries the full set: Archive, Move to top, Pin/Unpin, Settle/Un-settle, Fork, and Copy thread ID. Each lifecycle pair shows the one verb that applies, resolved from the same settled and pinned state as the sidebar row menu (so the three surfaces never disagree); Settle stays visible but disabled while the thread still has running or queued work, Pin and Settle appear only on environments advertising those capabilities, and Fork is withheld on archived threads the server would refuse. The palette also gains **Open archived threads** and a project-scoped **Open archived threads in <project>**, which open the archived settings page with that project's filter pre-applied.
- **Conversation navigation rails** — desktop threads keep the existing left-side user-message navigator and add a mirrored right-side navigator for completed agent responses, with hover previews and click-to-jump navigation. A reading position you scrolled to yourself is kept as the turn streams in, rather than being yanked back by delayed anchoring.
Expand Down
62 changes: 50 additions & 12 deletions apps/web/src/components/Sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -855,6 +855,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {
renamingTitle: string;
onContextMenu: (threadRef: ScopedThreadRef, position: { x: number; y: number }) => void;
onArchive: (threadRef: ScopedThreadRef) => void;
onFork: (threadRef: ScopedThreadRef) => void;
onSettle: (threadRef: ScopedThreadRef) => void;
onUnsettle: (threadRef: ScopedThreadRef) => void;
onSnooze: (threadRef: ScopedThreadRef, preset: SnoozePreset) => void;
Expand All @@ -877,6 +878,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {
onContextMenu,
onAcknowledgeWoke,
onArchive,
onFork,
onRenameTitleChange,
onSettle,
onSnooze,
Expand Down Expand Up @@ -1168,6 +1170,14 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {
},
[onArchive, threadRef],
);
const handleForkClick = useCallback(
(event: ReactMouseEvent) => {
event.preventDefault();
event.stopPropagation();
onFork(threadRef);
},
[onFork, threadRef],
);
const handleUnsettleClick = useCallback(
(event: ReactMouseEvent) => {
event.preventDefault();
Expand Down Expand Up @@ -1210,6 +1220,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {
const showSnoozeButton =
props.snoozeSupported && canSnooze(thread, { now: new Date().toISOString() });
const showArchiveButton = canArchiveThreadNow(thread);
const showForkButton = canForkConversation(thread);
// If the thread becomes blocked while the popover is open, the button
// unmounts without firing onOpenChange(false). Deriving the flag keeps a
// stale true from permanently hiding the status label / pinning the
Expand Down Expand Up @@ -1681,6 +1692,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {
{props.settlementSupported ||
props.pinningSupported ||
showSnoozeButton ||
showForkButton ||
showArchiveButton ? (
<span
className={cn(
Expand Down Expand Up @@ -1740,6 +1752,23 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {
<TooltipPopup>Settle thread</TooltipPopup>
</Tooltip>
) : null}
{showForkButton ? (
<Tooltip>
<TooltipTrigger
render={
<button
type="button"
aria-label="Fork conversation"
onClick={handleForkClick}
className="-mr-1 inline-flex cursor-pointer items-center rounded-md bg-transparent px-1.5 text-muted-foreground hover:text-foreground"
>
<GitBranchIcon className="size-3" />
</button>
}
/>
<TooltipPopup>Fork conversation</TooltipPopup>
</Tooltip>
) : null}
{showArchiveButton ? (
<Tooltip>
<TooltipTrigger
Expand Down Expand Up @@ -3305,6 +3334,24 @@ export default function Sidebar() {
},
[unsettleThread],
);
const attemptFork = useCallback(
(threadRef: ScopedThreadRef) => {
void (async () => {
const result = await forkThread(threadRef);
if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) {
const error = squashAtomCommandFailure(result);
toastManager.add(
stackedThreadToast({
type: "error",
title: "Failed to fork conversation",
description: error instanceof Error ? error.message : "An error occurred.",
}),
);
}
})();
},
[forkThread],
);
const attemptUnsnooze = useCallback(
(threadRef: ScopedThreadRef) => {
void (async () => {
Expand Down Expand Up @@ -3957,17 +4004,7 @@ export default function Sidebar() {
await attemptMoveThreadToTop(threadRef);
return;
case "fork": {
const result = await forkThread(threadRef);
if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) {
const error = squashAtomCommandFailure(result);
toastManager.add(
stackedThreadToast({
type: "error",
title: "Failed to fork conversation",
description: error instanceof Error ? error.message : "An error occurred.",
}),
);
}
attemptFork(threadRef);
return;
}
case "archive": {
Expand Down Expand Up @@ -4036,12 +4073,12 @@ export default function Sidebar() {
attemptUnsettle,
attemptUnsnooze,
attemptArchive,
attemptFork,
confirmThreadDelete,
copyBranchToClipboard,
copyPathToClipboard,
copyThreadIdToClipboard,
deleteThread,
forkThread,
handleMultiSelectContextMenu,
markThreadUnread,
projectCwdByKey,
Expand Down Expand Up @@ -4689,6 +4726,7 @@ export default function Sidebar() {
renamingTitle={renamingThreadKey === threadKey ? renamingTitle : ""}
onContextMenu={handleThreadContextMenu}
onArchive={attemptArchive}
onFork={attemptFork}
onSettle={attemptSettle}
onUnsettle={attemptUnsettle}
onSnooze={attemptSnooze}
Expand Down
43 changes: 28 additions & 15 deletions apps/web/src/hooks/useThreadActions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,10 @@ export class ThreadArchiveBlockedError extends Schema.TaggedErrorClass<ThreadArc
}

const archivingThreadKeys = new Set<string>();
// Forking replays the whole provider conversation, so it is slow and the
// source thread stays forkable the entire time. The guard lives here so every
// surface (hover quick-action, context menus, palette) inherits it.
const forkingThreadKeys = new Set<string>();

export class ThreadSettlementUnsupportedError extends Schema.TaggedErrorClass<ThreadSettlementUnsupportedError>()(
"ThreadSettlementUnsupportedError",
Expand Down Expand Up @@ -358,22 +362,31 @@ export function useThreadActions() {

const forkThread = useCallback(
async (target: ScopedThreadRef) => {
const result = await forkThreadMutation({
environmentId: target.environmentId,
input: { sourceThreadId: target.threadId },
});
if (result._tag === "Failure") {
return result;
const threadKey = scopedThreadKey(target);
// Interrupted, not a plain failure: callers already treat interrupts as
// silent no-ops, which is exactly right for a duplicate click.
if (forkingThreadKeys.has(threadKey)) return AsyncResult.failure(Cause.interrupt());
forkingThreadKeys.add(threadKey);
try {
const result = await forkThreadMutation({
environmentId: target.environmentId,
input: { sourceThreadId: target.threadId },
});
if (result._tag === "Failure") {
return result;
}
const navigationResult = await settlePromise(() =>
router.navigate({
to: "/$environmentId/$threadId",
params: buildThreadRouteParams(
scopeThreadRef(target.environmentId, result.value.threadId),
),
}),
);
return navigationResult._tag === "Failure" ? navigationResult : result;
} finally {
forkingThreadKeys.delete(threadKey);
}
const navigationResult = await settlePromise(() =>
router.navigate({
to: "/$environmentId/$threadId",
params: buildThreadRouteParams(
scopeThreadRef(target.environmentId, result.value.threadId),
),
}),
);
return navigationResult._tag === "Failure" ? navigationResult : result;
},
[forkThreadMutation, router],
);
Expand Down