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 @@ -8,7 +8,7 @@ This is a fork of T3 Code that integrates a few additional features, including:
- New Codex/Antigravity logins are picked up mid-session without a full restart.
- **Display mode per thread:** Right-click a thread and set Display mode to "Messages only" to hide the agent's work and see just your messages and the replies the agent sends to you with its `send_to_user` tool. The mode applies in the web and desktop apps, not in the mobile app.
- "Messages + Dashboard" adds a Dashboard tab to the right panel (+ → Dashboard): six status slots that the agent writes with its `dashboard_set` tool and reads with `dashboard_get`. Each slot shows how long ago it changed, and new text stays highlighted until you click the slot. Select text in a slot to quote it with Cite, as you would in a message.
- "Chat + Replies" keeps the main list short: it shows only first messages, and the replies to a message show in a Replies tab in the right panel (+ → Replies, or the reply line under a message). Point at a message and choose Reply to answer under it; the tab has its own reply box. Under each first message you see how many replies it has, how many are new, and a few generated words on what they are about. The agent answers a message with `send_to_user` and that message's id, so each answer lands under the message it answers.
- "Chat + Replies" keeps the main list short: it shows only first messages, and the replies to a message show in a Replies tab in the right panel (+ → Replies, or the reply line under a message). Point at a message and choose Reply to answer under it; the tab has its own reply box. Under each first message you see how many replies it has, how many are new, and a few generated words on what they are about. The agent answers a message with `send_to_user` and that message's id, so each answer lands under the message it answers. A Cite in the main list opens that message's replies and puts the quote in their reply box, unless the main box or the open reply box already has text; then the quote goes there, the main box first.
- In all three modes the agent sends one reply per message you type, and before a long task it may first send a short "on it" note; a thin line parts two agent messages in a row. To say more, it rewrites its reply in place with its `replace_send_to_user` tool. A rewritten reply shows a small "Edited" mark, and in Chat + Replies it counts as new.
- Copy buttons on pages in the preview browser now put the copied text into your own clipboard.
- **Two preview browser modes:** Settings → Browser → Preview browser picks the browser the server runs for preview tabs. **T3 native** (fast) is the default. It runs chrome-headless-shell, Chrome's small build that runs without a window. **Chrome** (slow) runs full Chrome, for pages that don't work in T3 native. On platforms where T3 has no Chrome for Testing build (Google's Chrome build for test automation), both choices run T3 native. Switching closes the open preview tabs.
Expand Down
19 changes: 2 additions & 17 deletions apps/web/src/components/ChatView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,6 @@ import {
AuthSourceControlWriteScope,
AuthPreviewOperateScope,
AuthTerminalOperateScope,
type AssistantCitation,
type ResponseContextRecord,
type ChatFileAttachment,
isProviderNativeSubagentThread,
Expand Down Expand Up @@ -132,7 +131,6 @@ import {
assistantCitationFromLocation,
useSideChatCitationReveal,
} from "../lib/assistantCitationNavigation";
import type { AssistantCitationSourceAnchor } from "~/lib/assistantTextSelection";
import { useStore } from "zustand";
import { shallow } from "zustand/vanilla/shallow";
import { useShallow } from "zustand/react/shallow";
Expand Down Expand Up @@ -368,6 +366,7 @@ import { ChatTerminalDrawers } from "./chat/ChatTerminalDrawers";
import { ChatRightPanel } from "./chat/ChatRightPanel";
import { useDashboardSurfaceAdder } from "../fork/dashboard/dashboardSurface";
import { REPLY_BOX_QUEUE_EDIT_BLOCKED, subThreadDraftId } from "../fork/subThreads/subThreadReply";
import { useSubThreadCite } from "../fork/subThreads/subThreadCite";
import { SubThreadViewContext } from "../fork/subThreads/subThreadsState";
import { useThreadShownView } from "../fork/displayMode/threadView";
import { timelineCitation } from "../fork/dashboard/dashboardCitation";
Expand Down Expand Up @@ -644,21 +643,7 @@ export default function ChatView(props: ChatViewProps) {
const composerFilesRef = useRef<ComposerFileAttachment[]>([]);
const composerTerminalContextsRef = useRef<TerminalContextDraft[]>([]);
const branchToolbarRef = useRef<BranchToolbarHandle>(null);
const citeAssistantText = (
citation: AssistantCitation,
sourceAnchor: AssistantCitationSourceAnchor,
) => {
const inserted = composerRef.current?.citeAssistantText(citation, sourceAnchor) ?? false;
if (!inserted) {
toastManager.add({
type: "warning",
title: "The composer is not ready",
description: "Try citing the selection after the connection or pending input is resolved.",
});
}
return inserted;
};
const citeAssistantTextStable = useStableCallback(citeAssistantText);
const citeAssistantTextStable = useSubThreadCite(embedded ? null : routeThreadRef, composerRef);
const replyToResponseStable = useStableCallback((response: ResponseContextRecord) => {
const accepted = composerRef.current?.replyToResponse(response) ?? false;
if (!accepted) {
Expand Down
2 changes: 2 additions & 0 deletions apps/web/src/components/RightPanelSheet.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { type ReactNode } from "react";

import { sheetInitialFocus } from "../fork/subThreads/subThreadCite";
import { Sheet, SheetHeader, SheetPopup, SheetTitle } from "./ui/sheet";

export function RightPanelSheet(props: {
Expand All @@ -22,6 +23,7 @@ export function RightPanelSheet(props: {
side="right"
showCloseButton={false}
keepMounted
initialFocus={sheetInitialFocus}
className="w-[min(42vw,28rem)] min-w-80 max-w-[28rem] max-[760px]:w-[min(88vw,24rem)] max-[760px]:min-w-0 wco:mt-(--workspace-topbar-height) wco:h-[calc(100%-var(--workspace-topbar-height))] wco:max-h-[calc(100%-var(--workspace-topbar-height))]"
>
<SheetHeader className="sr-only">
Expand Down
10 changes: 8 additions & 2 deletions apps/web/src/fork/subThreads/SubThreadsPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,11 @@ import { useAtomValue } from "@effect/atom-react";
import { scopedThreadKey } from "@t3tools/client-runtime/environment";
import type { ScopedThreadRef, ThreadSubThread } from "@t3tools/contracts";
import { ArrowLeftIcon } from "lucide-react";
import { useEffect, useState } from "react";
import { useEffect, useRef, useState } from "react";

import ChatView from "../../components/ChatView";
import { BackgroundThreadContext } from "../../components/chat/backgroundThread";
import type { ChatComposerHandle } from "../../components/chat/ChatComposer";
import { RelativeTime } from "../../components/RelativeTime";
import { ComposerHandleContext } from "../../composerHandleContext";
import type { SideChatParentPanel } from "../../lib/sideChatPane";
Expand All @@ -14,6 +15,7 @@ import { useQuietThreadProjection, useThreadHistory } from "../../state/entities
import { environmentThreadDetails, threadEnvironment } from "../../state/threads";
import { useAtomCommand } from "../../state/use-atom-command";
import { useThreadDisplayMode } from "../displayMode/useThreadDisplayMode";
import { registerReplyBox } from "./subThreadCite";
import {
markSubThreadRead,
type SubThreadSummary,
Expand Down Expand Up @@ -202,6 +204,10 @@ function SubThreadView(props: {
input: { threadId: threadRef.threadId, firstId: first, readAt: latestReply },
});
}, [props.visible, unread, latestReply, markRead, threadRef, first]);
const composer = useRef<ChatComposerHandle | null>(null);
const threadKey = scopedThreadKey(threadRef);
// oxlint-disable-next-line t3code/no-unreasoned-state-sync -- tells a Cite in the main list which reply box is mounted
useEffect(() => registerReplyBox(threadKey, first, composer), [threadKey, first]);
return (
<div data-side-chat-pane data-sub-thread-view={first} className="flex h-full min-h-0 flex-col">
<div className="flex h-9 shrink-0 items-center gap-1 border-border border-b pr-3 pl-1">
Expand All @@ -219,7 +225,7 @@ function SubThreadView(props: {
</div>
<SubThreadViewContext value={first}>
<BackgroundThreadContext value>
<ComposerHandleContext value={null}>
<ComposerHandleContext value={composer}>
<ChatView
environmentId={threadRef.environmentId}
threadId={threadRef.threadId}
Expand Down
115 changes: 115 additions & 0 deletions apps/web/src/fork/subThreads/subThreadCite.browser.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
import { createMemoryHistory } from "@tanstack/react-router";
import { scopedThreadKey, scopeThreadRef } from "@t3tools/client-runtime/environment";
import { DEFAULT_CLIENT_SETTINGS } from "@t3tools/contracts/settings";
import { collectAssistantCitations } from "@t3tools/shared/assistantCitations";
import { CHAT_REPLIES_DISPLAY_MODE } from "@t3tools/shared/displayModes";
import type * as Context from "effect/Context";
import { expect, it, vi } from "vite-plus/test";
import { page, userEvent } from "vite-plus/test/browser";

import { AppRoot } from "~/AppRoot";
// The route loads ChatView lazily, and its first transform takes seconds; see ChatView.browser.test.tsx.
import "~/components/ChatView";
import { useComposerDraftStore } from "~/composerDraftStore";
import { __setClientSettingsForTests } from "~/hooks/useSettings";
import { getRouter } from "~/router";
import {
assistantItem,
ENVIRONMENT_ID,
installFakeEnvironment,
threadWithHistory,
type FakeDriver,
} from "../../../test/fakeEnvironment";
import { render } from "../../../test/render";
import { subThreadDraftId } from "./subThreadReply";

vi.mock(
"../../../../../packages/client-runtime/src/connection/driver.ts",
async (importOriginal) => {
const [actual, { fakeDriver }, Layer] = await Promise.all([
importOriginal<{ readonly ConnectionDriver: Context.Key<unknown, FakeDriver> }>(),
import("../../../test/fakeEnvironment"),
import("effect/Layer"),
]);
return { ...actual, layer: Layer.succeed(actual.ConnectionDriver, fakeDriver) };
},
);

vi.mock(import("~/rpc/requestLatencyState"), async (original) => ({
...(await original()),
trackRpcRequestSent: () => {},
}));

function threadWithAgentSubThread(name: string) {
const history = threadWithHistory(name, 1);
const {
messageId: _id,
text: _text,
streaming: _streaming,
...item
} = assistantItem(history.id, history.runs[0]!.id, `${name}-news`, 2, "");
const news = {
...item,
type: "dynamic_tool" as const,
toolName: "mcp__t3-code__send_to_user",
input: { text: "The deploy is done.", new_sub_thread: true },
output: { ok: true },
};
return { thread: { ...history, items: [...history.items, news] }, first: news.id };
}

const layouts = {
"side panel": { width: 1194, ...threadWithAgentSubThread("cite-side-panel") },
sheet: { width: 414, ...threadWithAgentSubThread("cite-sheet") },
};
const threads = Object.values(layouts).map((layout) => layout.thread);
const settings = {
threadDisplayModes: Object.fromEntries(
threads.map((thread) => [thread.id, CHAT_REPLIES_DISPLAY_MODE.id]),
),
};

const waitForChatRepliesMode = () =>
expect.element(page.getByRole("button", { name: "Reply" }).first()).toBeInTheDocument();

const selectWithPointer = (text: string) => userEvent.tripleClick(page.getByText(text));

it.each(Object.keys(layouts) as Array<keyof typeof layouts>)(
"a Cite in the main list opens the message's replies in the %s, adds the quote to the saved reply draft and opens its comment box with focus",
async (layout) => {
const { width, thread, first } = layouts[layout];
const threadRef = scopeThreadRef(ENVIRONMENT_ID, thread.id);
const replyDraft = subThreadDraftId(scopedThreadKey(threadRef), first)!;
using _environment = installFakeEnvironment(threads, { settings });
__setClientSettingsForTests({
...DEFAULT_CLIENT_SETTINGS,
onboardingCompletedAt: "2026-01-01T00:00:00.000Z",
});
useComposerDraftStore.getState().setPrompt(replyDraft, "saved note");
await page.viewport(width, 834);
const router = getRouter(
createMemoryHistory({ initialEntries: [`/${ENVIRONMENT_ID}/${thread.id}`] }),
);
await router.load();
await using _app = await render(<AppRoot router={router} />, {
// AppRoot brings its own router.
router: false,
});
await waitForChatRepliesMode();
await selectWithPointer("The deploy is done.");
await page.getByRole("button", { name: "Cite selection in composer" }).click();

const comment = page.getByLabelText("Comment on selected text");
await expect.element(comment).toBeInTheDocument();
expect(document.querySelector(`[data-sub-thread-view="${first}"]`)).not.toBeNull();
await vi.waitFor(() => expect(document.querySelector("[data-starting-style]")).toBeNull());
await userEvent.keyboard("why?");
await expect.element(comment).toHaveValue("why?");
const reply = useComposerDraftStore.getState().getComposerDraft(replyDraft)?.prompt ?? "";
expect(reply).toContain("saved note");
expect(
collectAssistantCitations(reply).map(({ citation }) => [citation.messageId, citation.text]),
).toEqual([[first, "The deploy is done."]]);
expect(useComposerDraftStore.getState().getComposerDraft(threadRef)?.prompt ?? "").toBe("");
},
);
Loading
Loading