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
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
139 changes: 139 additions & 0 deletions apps/server/src/fork/displayModeWrap/NewThreadDisplayMode.test.ts
Original file line number Diff line number Diff line change
@@ -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>()("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<Case> = [
{ 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<string | undefined>(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 },
}),
),
),
);
});
72 changes: 72 additions & 0 deletions apps/server/src/fork/displayModeWrap/NewThreadDisplayMode.ts
Original file line number Diff line number Diff line change
@@ -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) =>
<A, E, R>(
effect: Effect.Effect<A, E, R>,
): 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,
),
);
};
3 changes: 2 additions & 1 deletion apps/server/src/ws.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -2189,7 +2190,7 @@ export const layerWsRpc = (
}),
createdBy: "user",
creationSource: input.creationSource ?? "web",
}),
}).pipe(NewThreadDisplayMode.forClientLaunch(input)),
),
Effect.provide(intakeContext),
),
Expand Down
2 changes: 2 additions & 0 deletions apps/web/src/components/settings/ProjectDefaultsSettings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -406,6 +407,7 @@ export function ProjectDefaultsSettings({ category }: { category: ProjectSetting
}
/>
{workspaceRow}
{isProjectScope ? null : <NewThreadDisplayModeSetting />}
</>
) : (
<>
Expand Down
8 changes: 8 additions & 0 deletions apps/web/src/components/settings/settingsSearch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
78 changes: 78 additions & 0 deletions apps/web/src/fork/displayMode/NewThreadDisplayModeSetting.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<SettingsRow
serverScoped
settingKeys={["newThreadDisplayMode"]}
{...searchableSetting("new-thread-display-mode")}
description="Mode for threads you start from the message box in the web or desktop app. Threads that agents, schedules, side chats or the mobile app start open in Normal. Existing threads keep their mode."
resetAction={
mode.id !== NORMAL_DISPLAY_MODE.id ? (
<SettingResetButton
label="display mode"
onClick={() => updateSettings({ newThreadDisplayMode: NORMAL_DISPLAY_MODE.id })}
/>
) : null
}
control={
<Select
value={mixed ? null : mode.id}
onValueChange={(value) => {
const next = DISPLAY_MODES.find((candidate) => candidate.id === value);
if (next !== undefined) updateSettings({ newThreadDisplayMode: next.id });
}}
>
<SelectTrigger size="sm" className="w-full sm:w-48" aria-label="New thread display mode">
<SelectValue>
{(value: string | null) =>
value === null ? "Mixed" : resolveDisplayMode(value).label
}
</SelectValue>
</SelectTrigger>
<SelectPopup align="end" alignItemWithTrigger={false}>
{DISPLAY_MODES.map((candidate) => (
<SelectItem hideIndicator key={candidate.id} value={candidate.id}>
{candidate.label}
</SelectItem>
))}
</SelectPopup>
</Select>
}
/>
);
}
5 changes: 5 additions & 0 deletions packages/contracts/src/settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down Expand Up @@ -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;

Expand Down
Loading