diff --git a/README.md b/README.md index a41497b820adc..d1af3d57f6e76 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,7 @@ This is a fork of T3 Code that integrates a few additional features, including: - "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. 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. + - Settings → Threads → New threads → Display mode picks the mode for threads you start from the message box in the web or desktop app. Threads that agents, scheduled tasks, side chats or the mobile app start open in Normal, and existing threads keep their mode. - 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. - **Compact when idle for Claude threads:** a choice in Settings → Providers → Advanced → Compact idle threads. Besides compacting before the prompt cache expires, it compacts a large idle Claude thread (default 400K tokens or more) a set time (default 15 minutes) after the last message or answer from a user or a bot, so the compaction runs while no one waits for a reply. The token counts and the wait are set right under the choice. diff --git a/apps/server/src/fork/displayModeWrap/NewThreadDisplayMode.test.ts b/apps/server/src/fork/displayModeWrap/NewThreadDisplayMode.test.ts new file mode 100644 index 0000000000000..51a5ddd4ccadc --- /dev/null +++ b/apps/server/src/fork/displayModeWrap/NewThreadDisplayMode.test.ts @@ -0,0 +1,139 @@ +import { assert, describe, it } from "@effect/vitest"; +import { type OrchestrationV2ThreadShell, ThreadId } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Ref from "effect/Ref"; +import * as Schema from "effect/Schema"; + +import * as ThreadManagementService from "../../orchestration-v2/ThreadManagementService.ts"; +import * as ServerSettings from "../../serverSettings.ts"; +import { type ClientLaunch, forClientLaunch } from "./NewThreadDisplayMode.ts"; + +class LaunchFailed extends Schema.TaggedError()("LaunchFailed", {}) {} + +const threadId = ThreadId.make("thread:new-display-mode"); +const failure = new LaunchFailed(); + +interface Case { + readonly name: string; + readonly launch?: ClientLaunch; + readonly defaultMode?: string; + readonly stored?: string; + /** When a thread with the id exists: before the launch, or only once the launch ran. */ + readonly exists?: "before" | "after"; + readonly fails?: true; + /** The thread lookup fails, so the mode cannot be checked. */ + readonly lookupFails?: true; + /** The thread's entry when the launch ran, and after it. */ + readonly seen: string | undefined; + readonly entry: string | undefined; +} + +const cases: ReadonlyArray = [ + { name: "a new web thread gets the default", seen: "chat-replies", entry: "chat-replies" }, + { + name: "a mobile thread keeps Normal", + launch: { threadId, creationSource: "mobile" }, + seen: undefined, + entry: undefined, + }, + { + name: "a launch with no creationSource keeps Normal", + launch: { threadId }, + seen: undefined, + entry: undefined, + }, + { + name: "a reused thread keeps its mode", + launch: { threadId, creationSource: "web", reuseExistingThread: true }, + seen: undefined, + entry: undefined, + }, + { + name: "a launch with no thread id writes nothing", + launch: { creationSource: "web" }, + seen: undefined, + entry: undefined, + }, + { + name: "an existing thread keeps its mode", + exists: "before", + seen: undefined, + entry: undefined, + }, + { + name: "a Normal default removes a stale entry", + defaultMode: "normal", + stored: "messages-only", + seen: undefined, + entry: undefined, + }, + { + name: "an unknown default keeps Normal", + defaultMode: "no-such-mode", + seen: undefined, + entry: undefined, + }, + { + name: "a failed thread lookup still runs the launch in Normal", + lookupFails: true, + seen: undefined, + entry: undefined, + }, + { + name: "a failed launch removes the entry", + fails: true, + seen: "chat-replies", + entry: undefined, + }, + { + name: "a failed launch that created the thread keeps the entry", + fails: true, + exists: "after", + seen: "chat-replies", + entry: "chat-replies", + }, +]; + +describe("NewThreadDisplayMode.forClientLaunch", () => { + it.effect.each(cases)("$name", (testCase) => + Effect.gen(function* () { + const settings = yield* ServerSettings.ServerSettingsService; + const created = yield* Ref.make(testCase.exists === "before"); + const seen = yield* Ref.make(undefined); + const entryNow = settings.getSettings.pipe( + Effect.map((current) => current.threadDisplayModes[threadId]), + ); + const launch = Effect.gen(function* () { + yield* Ref.set(seen, yield* entryNow); + if (testCase.exists === "after") yield* Ref.set(created, true); + if (testCase.fails) return yield* failure; + }); + const threads = Layer.mock(ThreadManagementService.ThreadManagementService)({ + getThreadShell: () => + testCase.lookupFails + ? Effect.die("thread lookup failed") + : Ref.get(created).pipe( + Effect.map((exists) => (exists ? ({} as OrchestrationV2ThreadShell) : null)), + ), + }); + + const error = yield* launch.pipe( + forClientLaunch(testCase.launch ?? { threadId, creationSource: "web" }), + Effect.provide(threads), + Effect.match({ onFailure: (error) => error, onSuccess: () => undefined }), + ); + + assert.strictEqual(error, testCase.fails ? failure : undefined); + assert.strictEqual(yield* Ref.get(seen), testCase.seen); + assert.strictEqual(yield* entryNow, testCase.entry); + }).pipe( + Effect.provide( + ServerSettings.layerTest({ + newThreadDisplayMode: testCase.defaultMode ?? "chat-replies", + threadDisplayModes: testCase.stored === undefined ? {} : { [threadId]: testCase.stored }, + }), + ), + ), + ); +}); diff --git a/apps/server/src/fork/displayModeWrap/NewThreadDisplayMode.ts b/apps/server/src/fork/displayModeWrap/NewThreadDisplayMode.ts new file mode 100644 index 0000000000000..c049af705438d --- /dev/null +++ b/apps/server/src/fork/displayModeWrap/NewThreadDisplayMode.ts @@ -0,0 +1,72 @@ +import type { OrchestrationV2CreationSource, ThreadId } from "@t3tools/contracts"; +import { NORMAL_DISPLAY_MODE, resolveDisplayMode } from "@t3tools/shared/displayModes"; +import * as Effect from "effect/Effect"; + +import * as ThreadManagementService from "../../orchestration-v2/ThreadManagementService.ts"; +import * as ServerSettings from "../../serverSettings.ts"; + +/** The raw fields of a client's `launchThread` request, before any default is applied. */ +export interface ClientLaunch { + readonly threadId?: ThreadId | undefined; + readonly reuseExistingThread?: boolean | undefined; + readonly creationSource?: OrchestrationV2CreationSource | undefined; +} + +/** Copies the default mode into a thread that does not exist yet; true when it wrote a mode entry. */ +const adopt = Effect.fn("NewThreadDisplayMode.adopt")( + function* (threadId: ThreadId) { + const settings = yield* ServerSettings.ServerSettingsService; + const current = yield* settings.getSettings; + const mode = resolveDisplayMode(current.newThreadDisplayMode); + const entry = mode.id === NORMAL_DISPLAY_MODE.id ? undefined : mode.id; + if (current.threadDisplayModes[threadId] === entry) return false; + const threads = yield* ThreadManagementService.ThreadManagementService; + if ((yield* threads.getThreadShell(threadId)) !== null) return false; + yield* settings.updateSettings({ threadDisplayModes: { [threadId]: entry ?? null } }); + return entry !== undefined; + }, + Effect.catchCause((cause) => + Effect.logWarning("New thread display mode not set", { cause }).pipe(Effect.as(false)), + ), +); + +const undo = Effect.fn("NewThreadDisplayMode.undo")( + function* (threadId: ThreadId) { + const threads = yield* ThreadManagementService.ThreadManagementService; + if ((yield* threads.getThreadShell(threadId)) !== null) return; + const settings = yield* ServerSettings.ServerSettingsService; + yield* settings.updateSettings({ threadDisplayModes: { [threadId]: null } }); + }, + Effect.catchCause((cause) => Effect.logWarning("New thread display mode not removed", { cause })), +); + +/** + * Opens a new thread that the user starts in the web or desktop app in the default display mode, + * before its first message goes out. Apply it only at the client `launchThread` RPC: MCP, scheduled + * tasks and server launches reach `ThreadLaunchService` another way, some with `creationSource: "web"`, + * and must keep Normal. + */ +export const forClientLaunch = + (launch: ClientLaunch) => + ( + effect: Effect.Effect, + ): Effect.Effect< + A, + E, + R | ServerSettings.ServerSettingsService | ThreadManagementService.ThreadManagementService + > => { + const threadId = launch.threadId; + // Scripts omit creationSource; the RPC handler counts them as web only for the launch itself. + if ( + launch.creationSource !== "web" || + launch.reuseExistingThread === true || + threadId === undefined + ) { + return effect; + } + return adopt(threadId).pipe( + Effect.flatMap((wrote) => + wrote ? effect.pipe(Effect.onError(() => undo(threadId))) : effect, + ), + ); + }; diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 3349e9545b9c5..9b677ec971402 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -199,6 +199,7 @@ import * as ServerBrowser from "./preview/ServerBrowser.ts"; import * as DeviceService from "./device/DeviceService.ts"; import * as ThreadDashboard from "./fork/threadDashboard/ThreadDashboard.ts"; import * as SubThreads from "./fork/subThreads/SubThreads.ts"; +import * as NewThreadDisplayMode from "./fork/displayModeWrap/NewThreadDisplayMode.ts"; import { shownWindow } from "./fork/shownHistory/ShownHistory.ts"; import { remoteSshDeviceHosts } from "./device/localSshDeviceHost.ts"; import * as PreviewManager from "./preview/Manager.ts"; @@ -2189,7 +2190,7 @@ export const layerWsRpc = ( }), createdBy: "user", creationSource: input.creationSource ?? "web", - }), + }).pipe(NewThreadDisplayMode.forClientLaunch(input)), ), Effect.provide(intakeContext), ), diff --git a/apps/web/src/components/settings/ProjectDefaultsSettings.tsx b/apps/web/src/components/settings/ProjectDefaultsSettings.tsx index 182b8bc5b1fdf..c006e5ad23b2a 100644 --- a/apps/web/src/components/settings/ProjectDefaultsSettings.tsx +++ b/apps/web/src/components/settings/ProjectDefaultsSettings.tsx @@ -8,6 +8,7 @@ import { createModelSelection } from "@t3tools/shared/model"; import { resolveProjectSettings } from "@t3tools/shared/projectSettings"; import { useNavigate } from "@tanstack/react-router"; +import { NewThreadDisplayModeSetting } from "../../fork/displayMode/NewThreadDisplayModeSetting"; import { useClientSettings } from "../../hooks/useSettings"; import { defaultModelSelectionsAreMixed, @@ -406,6 +407,7 @@ export function ProjectDefaultsSettings({ category }: { category: ProjectSetting } /> {workspaceRow} + {isProjectScope ? null : } ) : ( <> diff --git a/apps/web/src/components/settings/settingsSearch.ts b/apps/web/src/components/settings/settingsSearch.ts index 5558b2078e026..00fc60126c593 100644 --- a/apps/web/src/components/settings/settingsSearch.ts +++ b/apps/web/src/components/settings/settingsSearch.ts @@ -298,6 +298,14 @@ export const SETTINGS_SEARCH_ITEMS = [ searchTerms: ["fallback agent provider out of usage new threads"], environmentOnly: true, }, + { + id: "new-thread-display-mode", + title: "Display mode", + to: "/settings/threads", + scope: "environment-defaults", + searchTerms: ["new thread default display mode chat replies messages only dashboard normal"], + environmentOnly: true, + }, { id: "default-permissions", title: "Permissions", diff --git a/apps/web/src/fork/displayMode/NewThreadDisplayModeSetting.tsx b/apps/web/src/fork/displayMode/NewThreadDisplayModeSetting.tsx new file mode 100644 index 0000000000000..d02bd684e2ae7 --- /dev/null +++ b/apps/web/src/fork/displayMode/NewThreadDisplayModeSetting.tsx @@ -0,0 +1,78 @@ +import { + DISPLAY_MODES, + NORMAL_DISPLAY_MODE, + resolveDisplayMode, +} from "@t3tools/shared/displayModes"; + +import { useSettingsScope } from "~/components/settings/SettingsScopeContext"; +import { SettingResetButton, SettingsRow } from "~/components/settings/settingsLayout"; +import { searchableSetting } from "~/components/settings/settingsSearch"; +import { + useScopedSettings, + useScopedSettingsMixed, + useUpdateScopedSettings, +} from "~/components/settings/useScopedSettings"; +import { + Select, + SelectItem, + SelectPopup, + SelectTrigger, + SelectValue, +} from "~/components/ui/select"; + +export function NewThreadDisplayModeSetting() { + const { connectedEnvironments } = useSettingsScope(); + const mode = resolveDisplayMode(useScopedSettings((settings) => settings.newThreadDisplayMode)); + const mixed = useScopedSettingsMixed(["newThreadDisplayMode"]); + const updateSettings = useUpdateScopedSettings(); + if ( + connectedEnvironments.length === 0 || + !connectedEnvironments.every( + (environment) => + environment.serverConfig?.environment.capabilities.threadDisplayModes === true, + ) + ) { + return null; + } + + return ( + updateSettings({ newThreadDisplayMode: NORMAL_DISPLAY_MODE.id })} + /> + ) : null + } + control={ + + } + /> + ); +} diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index e5af7468d6043..2002a97316e54 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -1594,6 +1594,10 @@ export const ServerSettings = Schema.Struct({ threadDisplayModes: Schema.Record(TrimmedNonEmptyString, TrimmedNonEmptyString).pipe( Schema.withDecodingDefault(Effect.succeed({})), ), + /** The display mode id that new threads started by hand in the web or desktop app open in. */ + newThreadDisplayMode: TrimmedNonEmptyString.pipe( + Schema.withDecodingDefault(Effect.succeed("normal")), + ), /** Sent by the Usage tab with each summary request to this environment; edit in settings.json. Invalid values are ignored. */ usageModelFilter: ForwardCompatibleOptional(UsageModelFilter), @@ -1982,6 +1986,7 @@ export const ServerSettingsPatch = Schema.Struct({ threadDisplayModes: Schema.optionalKey( Schema.Record(TrimmedNonEmptyString, Schema.NullOr(TrimmedNonEmptyString)), ), + newThreadDisplayMode: Schema.optionalKey(TrimmedNonEmptyString), }); export type ServerSettingsPatch = typeof ServerSettingsPatch.Type;