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
19 changes: 19 additions & 0 deletions apps/web/src/components/AgentDetailView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ import {
ListFilterIcon,
MessageCircleIcon,
MessageSquarePlusIcon,
MessageSquareShareIcon,
SquareArrowOutUpRightIcon,
RefreshCw,
SearchIcon,
Expand Down Expand Up @@ -635,6 +636,7 @@ export function AgentDetailView(props: {
initialToolCallId?: string | null;
onBack: () => void;
onOpenInTab?: (() => void) | undefined;
onContinueInChat?: (() => void) | undefined;
}) {
const { agent } = props;
const { timestampFormat } = useClientSettings();
Expand Down Expand Up @@ -682,6 +684,23 @@ export function AgentDetailView(props: {
<TooltipPopup side="bottom">Open in new tab</TooltipPopup>
</Tooltip>
) : null}
{props.onContinueInChat ? (
<Tooltip>
<TooltipTrigger
render={
<Button
size="icon-micro"
variant="ghost-muted"
aria-label="Continue in chat"
onClick={props.onContinueInChat}
/>
}
>
<MessageSquareShareIcon aria-hidden />
</TooltipTrigger>
<TooltipPopup side="bottom">Continue in chat</TooltipPopup>
</Tooltip>
) : null}
</div>
</div>
<p className="truncate px-1 font-mono text-2xs text-muted-foreground">
Expand Down
42 changes: 37 additions & 5 deletions apps/web/src/components/AgentsPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
* - Hovering a row previews the agent (prompt, latest tool calls, usage);
* the preview stays open under the pointer, and clicking it or the row opens
* the agent's detail view. Right-click a row to open that agent in its own
* tab. Filters and sorts never move a row while it works
* tab or continue from its work in a new chat tab. Filters and sorts never move a row while it works
* (see applyAgentPanelView).
*/
import { useAtomValue } from "@effect/atom-react";
Expand All @@ -23,6 +23,7 @@ import {
findPanelAgent,
isAgentPanelViewFiltered,
type SubagentToolLogEntry,
subagentContinuationContext,
subagentResultChatContext,
} from "@t3tools/client-runtime/state/agentPanelView";
import type {
Expand Down Expand Up @@ -54,6 +55,7 @@ import { orchestrationEnvironment } from "~/state/orchestration";
import { ScrollArea } from "~/components/ui/scroll-area";
import { Button } from "~/components/ui/button";
import { PreviewCard, PreviewCardPopup, PreviewCardTrigger } from "~/components/ui/preview-card";
import { toastManager } from "~/components/ui/toast";
import { Tooltip, TooltipPopup, TooltipTrigger } from "~/components/ui/tooltip";

import { AgentUsageFooter, TOOL_KIND_ICONS, ToolLogList, UsageFooter } from "./AgentActivityParts";
Expand Down Expand Up @@ -245,29 +247,36 @@ function AgentPreviewContent(props: {
);
}

type AgentRowMenuAction = "open-in-tab" | "continue-in-chat" | "attach-result";

/** Right-click on a list row. The same actions as the detail view's icons. */
async function showAgentRowMenu(
actions: {
readonly openInTab?: (() => void) | undefined;
readonly continueInChat?: (() => void) | undefined;
readonly attachResult?: (() => void) | undefined;
},
position: { readonly x: number; readonly y: number },
): Promise<void> {
const api = readLocalApi();
if (!api) return;
const items: ContextMenuItem<"open-in-tab" | "attach-result">[] = [
const items: ContextMenuItem<AgentRowMenuAction>[] = [
...(actions.openInTab ? [{ id: "open-in-tab" as const, label: "Open in new tab" }] : []),
...(actions.continueInChat
? [{ id: "continue-in-chat" as const, label: "Continue in chat" }]
: []),
...(actions.attachResult
? [{ id: "attach-result" as const, label: "Attach result to chat" }]
: []),
];
let action: "open-in-tab" | "attach-result" | null;
let action: AgentRowMenuAction | null;
try {
action = await api.contextMenu.show(items, position);
} catch {
return;
}
if (action === "open-in-tab") actions.openInTab?.();
if (action === "continue-in-chat") actions.continueInChat?.();
if (action === "attach-result") actions.attachResult?.();
}

Expand All @@ -286,12 +295,14 @@ function AgentRow(props: {
timestampFormat: TimestampFormat;
onOpen: OpenAgent;
onOpenInTab?: ((agent: RuntimeSubagent) => void) | undefined;
onContinueInChat?: ((agent: RuntimeSubagent) => void) | undefined;
}) {
const { agent, onOpen, onOpenInTab } = props;
const { agent, onOpen, onOpenInTab, onContinueInChat } = props;
const toolLog = props.toolLogs.get(agent.id) ?? NO_TOOL_CALLS;
const composerRef = useComposerHandleContext();
const menuActions = {
openInTab: onOpenInTab ? () => onOpenInTab(agent) : undefined,
continueInChat: onContinueInChat ? () => onContinueInChat(agent) : undefined,
attachResult:
subagentResultChatContext(agent) !== null
? () => attachAgentResultToChat(composerRef, agent)
Expand All @@ -318,7 +329,7 @@ function AgentRow(props: {
type="button"
onClick={() => onOpen(agent.id)}
onContextMenu={
menuActions.openInTab || menuActions.attachResult
menuActions.openInTab || menuActions.continueInChat || menuActions.attachResult
? (event) => {
event.preventDefault();
event.stopPropagation();
Expand Down Expand Up @@ -355,6 +366,7 @@ interface RowContext {
readonly timestampFormat: TimestampFormat;
readonly onOpenAgent: OpenAgent;
readonly onOpenInTab?: ((agent: RuntimeSubagent) => void) | undefined;
readonly onContinueInChat?: ((agent: RuntimeSubagent) => void) | undefined;
}

function Rows(props: { agents: ReadonlyArray<RuntimeSubagent>; ctx: RowContext }) {
Expand All @@ -366,6 +378,7 @@ function Rows(props: { agents: ReadonlyArray<RuntimeSubagent>; ctx: RowContext }
timestampFormat={props.ctx.timestampFormat}
onOpen={props.ctx.onOpenAgent}
onOpenInTab={props.ctx.onOpenInTab}
onContinueInChat={props.ctx.onContinueInChat}
/>
));
}
Expand Down Expand Up @@ -708,10 +721,13 @@ export function AgentsPanel({
workspaceRoot,
dedicatedAgentId,
onOpenAgentTab,
onContinueInChat,
onViewAgents,
}: {
dedicatedAgentId?: string | undefined;
onOpenAgentTab?: ((agent: RuntimeSubagent) => void) | undefined;
/** Opens a chat tab seeded with the agent's work, as captured by `subagentContinuationContext`. */
onContinueInChat?: ((agent: RuntimeSubagent, context: string) => void) | undefined;
onViewAgents?: (() => void) | undefined;
model: AgentPanelModel;
activities: ReadonlyArray<OrchestrationThreadActivity>;
Expand All @@ -737,13 +753,28 @@ export function AgentsPanel({
);
const detailAgentId = dedicatedAgentId ?? focusedAgentId;
const focusedAgent = detailAgentId ? findPanelAgent(model, detailAgentId) : null;
const continueInChat = onContinueInChat
? (agent: RuntimeSubagent) => {
const context = subagentContinuationContext(agent, toolLogs.get(agent.id) ?? NO_TOOL_CALLS);
if (context !== null) {
onContinueInChat(agent, context);
return;
}
toastManager.add({
type: "info",
title: "Nothing to continue from yet",
description: "This agent has no task, result, or tool calls to carry over.",
});
}
: undefined;
const ctx: RowContext = {
toolLogs,
timestampFormat,
onOpenAgent: (agentId, toolCallId) => {
if (threadKey) focusAgent(threadKey, agentId, toolCallId ?? null);
},
onOpenInTab: onOpenAgentTab,
onContinueInChat: continueInChat,
};

if (dedicatedAgentId && !focusedAgent) {
Expand Down Expand Up @@ -786,6 +817,7 @@ export function AgentsPanel({
onOpenInTab={
!dedicatedAgentId && onOpenAgentTab ? () => onOpenAgentTab(focusedAgent) : undefined
}
onContinueInChat={continueInChat ? () => continueInChat(focusedAgent) : undefined}
onBack={() => {
if (dedicatedAgentId) onViewAgents?.();
else if (threadKey) focusAgent(threadKey, null);
Expand Down
22 changes: 22 additions & 0 deletions apps/web/src/components/ChatView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -239,6 +239,7 @@ import { WizardPopup } from "./ui/wizard";
import {
deriveAgentPanelModel,
foldSubagentActivities,
type RuntimeSubagent,
} from "@t3tools/client-runtime/state/subagentRuntime";
import { BranchToolbar, type BranchToolbarHandle } from "./BranchToolbar";
import { resolveShortcutCommand, shortcutLabelForCommand } from "../keybindings";
Expand Down Expand Up @@ -7509,6 +7510,26 @@ export default function ChatView(props: ChatViewProps) {
await openForkedTab(tabRef);
});

// From the Agents panel: a new tab of this thread whose draft carries the agent's work.
const onContinueAgentInChat = (agent: RuntimeSubagent, context: string) =>
runFork(async (connection) => {
if (!activeThread) return;
const tabRef = await forkThreadTab(connection, {
environmentId,
sourceThreadId: activeThread.id,
sourceTitle: activeThread.title,
modelSelection: activeThread.modelSelection,
prompt: "",
hasHistory: false,
context: {
producerId: `${activeThread.id}-agent-${agent.id}`,
title: agent.title,
summary: context,
},
});
await openForkedTab(tabRef);
});

// From the model picker: the whole tab is summarized, the new tab runs the picked model, and
// this tab's draft is copied after the summary. The draft here is left as it was.
// `emptyDraftPrompt` stands in for the draft when there is none.
Expand Down Expand Up @@ -10217,6 +10238,7 @@ export default function ChatView(props: ChatViewProps) {
}
: undefined
}
onContinueInChat={isServerThread ? onContinueAgentInChat : undefined}
onViewAgents={() => {
if (!activeThreadRef) return;
useAgentsPanelStore.getState().focusAgent(scopedThreadKey(activeThreadRef), null);
Expand Down
49 changes: 34 additions & 15 deletions apps/web/src/components/chat/ThreadTabs.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -489,18 +489,30 @@ async function captureThreadTabContext(
summary?: string;
},
): Promise<ComposerContextReference> {
const { threadId, sourceThreadId, title } = input;
const summary = input.summary ?? (await fetchThreadTabSummary(connection, input));
const contextId = toKindScopedComposerContextId("thread-tab", sourceThreadId);
const label = sanitizeComposerContextLabel(title, "thread-tab");
return storeThreadTabContext(input.threadId, {
producerId: input.sourceThreadId,
sourceThreadId: input.sourceThreadId,
title: input.title,
summary,
});
}

/** Holds `summary` behind a chat-summary chip in the draft of `threadId`. */
function storeThreadTabContext(
threadId: ThreadId,
input: { producerId: string; sourceThreadId: ThreadId; title: string; summary: string },
): ComposerContextReference {
const contextId = toKindScopedComposerContextId("thread-tab", input.producerId);
const label = sanitizeComposerContextLabel(input.title, "thread-tab");
useThreadTabContextStore.getState().upsert(threadId, {
version: 1,
kind: "thread-tab",
contextId,
label,
threadId: sourceThreadId,
title: title.slice(0, 2_048),
summary,
threadId: input.sourceThreadId,
title: input.title.slice(0, 2_048),
summary: input.summary.slice(0, COMPOSER_CONTEXT_THREAD_TAB_SUMMARY_MAX_CHARS),
});
return { kind: "thread-tab", contextId, label } satisfies ComposerContextReference;
}
Expand Down Expand Up @@ -546,6 +558,11 @@ export async function forkThreadTab(
prompt: string;
/** False when forking from the chat's first message, which leaves nothing to summarize. */
hasHistory: boolean;
/**
* Context captured by the caller, such as a subagent's work, attached in place of the source
* chat's summary. `producerId` keeps its chip distinct from the chat's own.
*/
context?: { producerId: string; title: string; summary: string };
},
): Promise<ScopedThreadRef> {
const threadId = newThreadId();
Expand All @@ -557,15 +574,17 @@ export async function forkThreadTab(
);
const threadRef = scopeThreadRef(input.environmentId, threadId);
carryRightPanelVisibility(scopeThreadRef(input.environmentId, input.sourceThreadId), threadRef);
const reference = input.hasHistory
? await captureThreadTabContext(connection, {
threadId,
sourceThreadId: input.sourceThreadId,
title: input.sourceTitle,
...(input.beforeMessageId ? { beforeMessageId: input.beforeMessageId } : {}),
...(input.afterMessageId ? { afterMessageId: input.afterMessageId } : {}),
})
: null;
const reference = input.context
? storeThreadTabContext(threadId, { ...input.context, sourceThreadId: input.sourceThreadId })
: input.hasHistory
? await captureThreadTabContext(connection, {
threadId,
sourceThreadId: input.sourceThreadId,
title: input.sourceTitle,
...(input.beforeMessageId ? { beforeMessageId: input.beforeMessageId } : {}),
...(input.afterMessageId ? { afterMessageId: input.afterMessageId } : {}),
})
: null;
useComposerDraftStore
.getState()
.setPrompt(
Expand Down
4 changes: 4 additions & 0 deletions docs/fork-differences.md
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,10 @@ Upstream's panel is a fixed list with one summary line per agent. To feed it:
serves it with `retainedAt` once the provider can no longer answer.
- **Attach result to chat** (detail view and list right-click) pastes the agent's task and
result into the composer; `subagentResultChatContext` builds the text.
- **Continue in chat** (detail view and list right-click) opens a new chat tab of the thread whose
draft carries the agent's task, result or error, and latest tool calls as a chat-summary chip;
`subagentContinuationContext` builds the text. It starts a fresh conversation rather than
resuming the agent's provider session.

Code: `apps/web/src/components/AgentsPanel.tsx`, `AgentDetailView.tsx`,
`apps/web/src/rightPanelStore.ts`, `packages/client-runtime/src/state/agentPanelView.ts`,
Expand Down
6 changes: 6 additions & 0 deletions docs/user/thread-sidebar.md
Original file line number Diff line number Diff line change
Expand Up @@ -280,6 +280,12 @@ chat** next to its result, or right-click the agent in the list. T3 Code adds
the agent's task and result to the composer; a long result becomes a pasted
attachment.

To pick up an agent's line of work in its own conversation, choose **Continue
in chat** in the agent's detail view, or right-click the agent in the list. A
new chat tab opens with the agent's task, result, and tool calls attached, so
you can ask follow-up questions or push the investigation further. The new
chat starts fresh with that context; it does not resume the agent itself.

| Provider | Tool calls | Prompt | Transcript |
| ----------- | ---------- | --------------------- | ---------- |
| Claude | Yes | Yes | Yes |
Expand Down
Loading