authorgravatar for 91240327+shivamhwp@users.noreply.github.comshivam <91240327+shivamhwp@users.noreply.github.com> 2026-03-28 23:18:34+05:30
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-03-28 10:48:34-07:00
logf0cdaf94c2b69a0f6a55f7ca2e3d13782b4272a7
treeba0443dd8f86b61b91979b0c5f292e9d2d26d700
parent48196e67a73f1f8ed8949254306f3cf41e4680bb
signature Signed by PGP key B5690EEEBB952194

Add thread archiving and settings navigation (#1359)

Co-authored-by: Julius Marminge <julius0216@outlook.com>

53 files changed, 2899 insertions(+), 1799 deletions(-)

apps/desktop/src/main.ts+2
......@@ -1141,6 +1141,7 @@ function registerIpcHandlers(): void {
11411141 id: item.id,
11421142 label: item.label,
11431143 destructive: item.destructive === true,
1144 disabled: item.disabled === true,
11441145 }));
11451146 if (normalizedItems.length === 0) {
11461147 return null;
......@@ -1171,6 +1172,7 @@ function registerIpcHandlers(): void {
11711172 }
11721173 const itemOption: MenuItemConstructorOptions = {
11731174 label: item.label,
1175 enabled: !item.disabled,
11741176 click: () => resolve(item.id),
11751177 };
11761178 if (item.destructive) {
apps/server/src/checkpointing/Layers/CheckpointDiffQuery.test.ts+1
......@@ -61,6 +61,7 @@ function makeSnapshot(input: {
6161 },
6262 createdAt: "2026-01-01T00:00:00.000Z",
6363 updatedAt: "2026-01-01T00:00:00.000Z",
64 archivedAt: null,
6465 deletedAt: null,
6566 messages: [],
6667 activities: [],
apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts+67
......@@ -120,6 +120,73 @@ describe("OrchestrationEngine", () => {
120120 await system.dispose();
121121 });
122122
123 it("archives and unarchives threads through orchestration commands", async () => {
124 const system = await createOrchestrationSystem();
125 const { engine } = system;
126 const createdAt = now();
127
128 await system.run(
129 engine.dispatch({
130 type: "project.create",
131 commandId: CommandId.makeUnsafe("cmd-project-archive-create"),
132 projectId: asProjectId("project-archive"),
133 title: "Project Archive",
134 workspaceRoot: "/tmp/project-archive",
135 defaultModelSelection: {
136 provider: "codex",
137 model: "gpt-5-codex",
138 },
139 createdAt,
140 }),
141 );
142 await system.run(
143 engine.dispatch({
144 type: "thread.create",
145 commandId: CommandId.makeUnsafe("cmd-thread-archive-create"),
146 threadId: ThreadId.makeUnsafe("thread-archive"),
147 projectId: asProjectId("project-archive"),
148 title: "Archive me",
149 modelSelection: {
150 provider: "codex",
151 model: "gpt-5-codex",
152 },
153 interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE,
154 runtimeMode: "full-access",
155 branch: null,
156 worktreePath: null,
157 createdAt,
158 }),
159 );
160
161 await system.run(
162 engine.dispatch({
163 type: "thread.archive",
164 commandId: CommandId.makeUnsafe("cmd-thread-archive"),
165 threadId: ThreadId.makeUnsafe("thread-archive"),
166 }),
167 );
168 expect(
169 (await system.run(engine.getReadModel())).threads.find(
170 (thread) => thread.id === "thread-archive",
171 )?.archivedAt,
172 ).not.toBeNull();
173
174 await system.run(
175 engine.dispatch({
176 type: "thread.unarchive",
177 commandId: CommandId.makeUnsafe("cmd-thread-unarchive"),
178 threadId: ThreadId.makeUnsafe("thread-archive"),
179 }),
180 );
181 expect(
182 (await system.run(engine.getReadModel())).threads.find(
183 (thread) => thread.id === "thread-archive",
184 )?.archivedAt,
185 ).toBeNull();
186
187 await system.dispose();
188 });
189
123190 it("replays append-only events from sequence", async () => {
124191 const system = await createOrchestrationSystem();
125192 const { engine } = system;
apps/server/src/orchestration/Layers/ProjectionPipeline.ts+31
......@@ -428,10 +428,41 @@ const makeOrchestrationProjectionPipeline = Effect.gen(function* () {
428428 latestTurnId: null,
429429 createdAt: event.payload.createdAt,
430430 updatedAt: event.payload.updatedAt,
431 archivedAt: null,
431432 deletedAt: null,
432433 });
433434 return;
434435
436 case "thread.archived": {
437 const existingRow = yield* projectionThreadRepository.getById({
438 threadId: event.payload.threadId,
439 });
440 if (Option.isNone(existingRow)) {
441 return;
442 }
443 yield* projectionThreadRepository.upsert({
444 ...existingRow.value,
445 archivedAt: event.payload.archivedAt,
446 updatedAt: event.payload.updatedAt,
447 });
448 return;
449 }
450
451 case "thread.unarchived": {
452 const existingRow = yield* projectionThreadRepository.getById({
453 threadId: event.payload.threadId,
454 });
455 if (Option.isNone(existingRow)) {
456 return;
457 }
458 yield* projectionThreadRepository.upsert({
459 ...existingRow.value,
460 archivedAt: null,
461 updatedAt: event.payload.updatedAt,
462 });
463 return;
464 }
465
435466 case "thread.meta-updated": {
436467 const existingRow = yield* projectionThreadRepository.getById({
437468 threadId: event.payload.threadId,
apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts+1
......@@ -279,6 +279,7 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => {
279279 },
280280 createdAt: "2026-02-24T00:00:02.000Z",
281281 updatedAt: "2026-02-24T00:00:03.000Z",
282 archivedAt: null,
282283 deletedAt: null,
283284 messages: [
284285 {
apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts+2
......@@ -174,6 +174,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {
174174 latest_turn_id AS "latestTurnId",
175175 created_at AS "createdAt",
176176 updated_at AS "updatedAt",
177 archived_at AS "archivedAt",
177178 deleted_at AS "deletedAt"
178179 FROM projection_threads
179180 ORDER BY created_at ASC, thread_id ASC
......@@ -560,6 +561,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {
560561 latestTurn: latestTurnByThread.get(row.threadId) ?? null,
561562 createdAt: row.createdAt,
562563 updatedAt: row.updatedAt,
564 archivedAt: row.archivedAt,
563565 deletedAt: row.deletedAt,
564566 messages: messagesByThread.get(row.threadId) ?? [],
565567 proposedPlans: proposedPlansByThread.get(row.threadId) ?? [],
apps/server/src/orchestration/Schemas.ts+4
......@@ -3,10 +3,12 @@ import {
33 ProjectMetaUpdatedPayload as ContractsProjectMetaUpdatedPayloadSchema,
44 ProjectDeletedPayload as ContractsProjectDeletedPayloadSchema,
55 ThreadCreatedPayload as ContractsThreadCreatedPayloadSchema,
6 ThreadArchivedPayload as ContractsThreadArchivedPayloadSchema,
67 ThreadMetaUpdatedPayload as ContractsThreadMetaUpdatedPayloadSchema,
78 ThreadRuntimeModeSetPayload as ContractsThreadRuntimeModeSetPayloadSchema,
89 ThreadInteractionModeSetPayload as ContractsThreadInteractionModeSetPayloadSchema,
910 ThreadDeletedPayload as ContractsThreadDeletedPayloadSchema,
11 ThreadUnarchivedPayload as ContractsThreadUnarchivedPayloadSchema,
1012 ThreadMessageSentPayload as ContractsThreadMessageSentPayloadSchema,
1113 ThreadProposedPlanUpsertedPayload as ContractsThreadProposedPlanUpsertedPayloadSchema,
1214 ThreadSessionSetPayload as ContractsThreadSessionSetPayloadSchema,
......@@ -26,10 +28,12 @@ export const ProjectMetaUpdatedPayload = ContractsProjectMetaUpdatedPayloadSchem
2628export const ProjectDeletedPayload = ContractsProjectDeletedPayloadSchema;
2729
2830export const ThreadCreatedPayload = ContractsThreadCreatedPayloadSchema;
31export const ThreadArchivedPayload = ContractsThreadArchivedPayloadSchema;
2932export const ThreadMetaUpdatedPayload = ContractsThreadMetaUpdatedPayloadSchema;
3033export const ThreadRuntimeModeSetPayload = ContractsThreadRuntimeModeSetPayloadSchema;
3134export const ThreadInteractionModeSetPayload = ContractsThreadInteractionModeSetPayloadSchema;
3235export const ThreadDeletedPayload = ContractsThreadDeletedPayloadSchema;
36export const ThreadUnarchivedPayload = ContractsThreadUnarchivedPayloadSchema;
3337
3438export const MessageSentPayloadSchema = ContractsThreadMessageSentPayloadSchema;
3539export const ThreadProposedPlanUpsertedPayload = ContractsThreadProposedPlanUpsertedPayloadSchema;
apps/server/src/orchestration/commandInvariants.test.ts+2
......@@ -66,6 +66,7 @@ const readModel: OrchestrationReadModel = {
6666 worktreePath: null,
6767 createdAt: now,
6868 updatedAt: now,
69 archivedAt: null,
6970 latestTurn: null,
7071 messages: [],
7172 session: null,
......@@ -88,6 +89,7 @@ const readModel: OrchestrationReadModel = {
8889 worktreePath: null,
8990 createdAt: now,
9091 updatedAt: now,
92 archivedAt: null,
9193 latestTurn: null,
9294 messages: [],
9395 session: null,
apps/server/src/orchestration/commandInvariants.ts+38
......@@ -88,6 +88,44 @@ export function requireThread(input: {
8888 );
8989}
9090
91export function requireThreadArchived(input: {
92 readonly readModel: OrchestrationReadModel;
93 readonly command: OrchestrationCommand;
94 readonly threadId: ThreadId;
95}): Effect.Effect<OrchestrationThread, OrchestrationCommandInvariantError> {
96 return requireThread(input).pipe(
97 Effect.flatMap((thread) =>
98 thread.archivedAt !== null
99 ? Effect.succeed(thread)
100 : Effect.fail(
101 invariantError(
102 input.command.type,
103 `Thread '${input.threadId}' is not archived for command '${input.command.type}'.`,
104 ),
105 ),
106 ),
107 );
108}
109
110export function requireThreadNotArchived(input: {
111 readonly readModel: OrchestrationReadModel;
112 readonly command: OrchestrationCommand;
113 readonly threadId: ThreadId;
114}): Effect.Effect<OrchestrationThread, OrchestrationCommandInvariantError> {
115 return requireThread(input).pipe(
116 Effect.flatMap((thread) =>
117 thread.archivedAt === null
118 ? Effect.succeed(thread)
119 : Effect.fail(
120 invariantError(
121 input.command.type,
122 `Thread '${input.threadId}' is already archived and cannot handle command '${input.command.type}'.`,
123 ),
124 ),
125 ),
126 );
127}
128
91129export function requireThreadAbsent(input: {
92130 readonly readModel: OrchestrationReadModel;
93131 readonly command: OrchestrationCommand;
apps/server/src/orchestration/decider.ts+47
......@@ -10,7 +10,9 @@ import {
1010 requireProject,
1111 requireProjectAbsent,
1212 requireThread,
13 requireThreadArchived,
1314 requireThreadAbsent,
15 requireThreadNotArchived,
1416} from "./commandInvariants.ts";
1517
1618const nowIso = () => new Date().toISOString();
......@@ -190,6 +192,51 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand"
190192 };
191193 }
192194
195 case "thread.archive": {
196 yield* requireThreadNotArchived({
197 readModel,
198 command,
199 threadId: command.threadId,
200 });
201 const occurredAt = nowIso();
202 return {
203 ...withEventBase({
204 aggregateKind: "thread",
205 aggregateId: command.threadId,
206 occurredAt,
207 commandId: command.commandId,
208 }),
209 type: "thread.archived",
210 payload: {
211 threadId: command.threadId,
212 archivedAt: occurredAt,
213 updatedAt: occurredAt,
214 },
215 };
216 }
217
218 case "thread.unarchive": {
219 yield* requireThreadArchived({
220 readModel,
221 command,
222 threadId: command.threadId,
223 });
224 const occurredAt = nowIso();
225 return {
226 ...withEventBase({
227 aggregateKind: "thread",
228 aggregateId: command.threadId,
229 occurredAt,
230 commandId: command.commandId,
231 }),
232 type: "thread.unarchived",
233 payload: {
234 threadId: command.threadId,
235 updatedAt: occurredAt,
236 },
237 };
238 }
239
193240 case "thread.meta.update": {
194241 yield* requireThread({
195242 readModel,
apps/server/src/orchestration/projector.test.ts+73
......@@ -87,6 +87,7 @@ describe("orchestration projector", () => {
8787 latestTurn: null,
8888 createdAt: now,
8989 updatedAt: now,
90 archivedAt: null,
9091 deletedAt: null,
9192 messages: [],
9293 proposedPlans: [],
......@@ -131,6 +132,78 @@ describe("orchestration projector", () => {
131132 ).rejects.toBeDefined();
132133 });
133134
135 it("applies thread.archived and thread.unarchived events", async () => {
136 const now = new Date().toISOString();
137 const later = new Date(Date.parse(now) + 1_000).toISOString();
138 const created = await Effect.runPromise(
139 projectEvent(
140 createEmptyReadModel(now),
141 makeEvent({
142 sequence: 1,
143 type: "thread.created",
144 aggregateKind: "thread",
145 aggregateId: "thread-1",
146 occurredAt: now,
147 commandId: "cmd-thread-create",
148 payload: {
149 threadId: "thread-1",
150 projectId: "project-1",
151 title: "demo",
152 modelSelection: {
153 provider: "codex",
154 model: "gpt-5-codex",
155 },
156 runtimeMode: "full-access",
157 interactionMode: "default",
158 branch: null,
159 worktreePath: null,
160 createdAt: now,
161 updatedAt: now,
162 },
163 }),
164 ),
165 );
166
167 const archived = await Effect.runPromise(
168 projectEvent(
169 created,
170 makeEvent({
171 sequence: 2,
172 type: "thread.archived",
173 aggregateKind: "thread",
174 aggregateId: "thread-1",
175 occurredAt: later,
176 commandId: "cmd-thread-archive",
177 payload: {
178 threadId: "thread-1",
179 archivedAt: later,
180 updatedAt: later,
181 },
182 }),
183 ),
184 );
185 expect(archived.threads[0]?.archivedAt).toBe(later);
186
187 const unarchived = await Effect.runPromise(
188 projectEvent(
189 archived,
190 makeEvent({
191 sequence: 3,
192 type: "thread.unarchived",
193 aggregateKind: "thread",
194 aggregateId: "thread-1",
195 occurredAt: later,
196 commandId: "cmd-thread-unarchive",
197 payload: {
198 threadId: "thread-1",
199 updatedAt: later,
200 },
201 }),
202 ),
203 );
204 expect(unarchived.threads[0]?.archivedAt).toBeNull();
205 });
206
134207 it("keeps projector forward-compatible for unhandled event types", async () => {
135208 const now = new Date().toISOString();
136209 const model = createEmptyReadModel(now);
apps/server/src/orchestration/projector.ts+25
......@@ -14,12 +14,14 @@ import {
1414 ProjectDeletedPayload,
1515 ProjectMetaUpdatedPayload,
1616 ThreadActivityAppendedPayload,
17 ThreadArchivedPayload,
1718 ThreadCreatedPayload,
1819 ThreadDeletedPayload,
1920 ThreadInteractionModeSetPayload,
2021 ThreadMetaUpdatedPayload,
2122 ThreadProposedPlanUpsertedPayload,
2223 ThreadRuntimeModeSetPayload,
24 ThreadUnarchivedPayload,
2325 ThreadRevertedPayload,
2426 ThreadSessionSetPayload,
2527 ThreadTurnDiffCompletedPayload,
......@@ -260,6 +262,7 @@ export function projectEvent(
260262 latestTurn: null,
261263 createdAt: payload.createdAt,
262264 updatedAt: payload.updatedAt,
265 archivedAt: null,
263266 deletedAt: null,
264267 messages: [],
265268 activities: [],
......@@ -289,6 +292,28 @@ export function projectEvent(
289292 })),
290293 );
291294
295 case "thread.archived":
296 return decodeForEvent(ThreadArchivedPayload, event.payload, event.type, "payload").pipe(
297 Effect.map((payload) => ({
298 ...nextBase,
299 threads: updateThread(nextBase.threads, payload.threadId, {
300 archivedAt: payload.archivedAt,
301 updatedAt: payload.updatedAt,
302 }),
303 })),
304 );
305
306 case "thread.unarchived":
307 return decodeForEvent(ThreadUnarchivedPayload, event.payload, event.type, "payload").pipe(
308 Effect.map((payload) => ({
309 ...nextBase,
310 threads: updateThread(nextBase.threads, payload.threadId, {
311 archivedAt: null,
312 updatedAt: payload.updatedAt,
313 }),
314 })),
315 );
316
292317 case "thread.meta-updated":
293318 return decodeForEvent(ThreadMetaUpdatedPayload, event.payload, event.type, "payload").pipe(
294319 Effect.map((payload) => ({
apps/server/src/persistence/Layers/ProjectionRepositories.test.ts+1
......@@ -87,6 +87,7 @@ projectionRepositoriesLayer("Projection repositories", (it) => {
8787 latestTurnId: null,
8888 createdAt: "2026-03-24T00:00:00.000Z",
8989 updatedAt: "2026-03-24T00:00:00.000Z",
90 archivedAt: null,
9091 deletedAt: null,
9192 });
9293
apps/server/src/persistence/Layers/ProjectionThreads.ts+5
......@@ -39,6 +39,7 @@ const makeProjectionThreadRepository = Effect.gen(function* () {
3939 latest_turn_id,
4040 created_at,
4141 updated_at,
42 archived_at,
4243 deleted_at
4344 )
4445 VALUES (
......@@ -53,6 +54,7 @@ const makeProjectionThreadRepository = Effect.gen(function* () {
5354 ${row.latestTurnId},
5455 ${row.createdAt},
5556 ${row.updatedAt},
57 ${row.archivedAt},
5658 ${row.deletedAt}
5759 )
5860 ON CONFLICT (thread_id)
......@@ -67,6 +69,7 @@ const makeProjectionThreadRepository = Effect.gen(function* () {
6769 latest_turn_id = excluded.latest_turn_id,
6870 created_at = excluded.created_at,
6971 updated_at = excluded.updated_at,
72 archived_at = excluded.archived_at,
7073 deleted_at = excluded.deleted_at
7174 `,
7275 });
......@@ -88,6 +91,7 @@ const makeProjectionThreadRepository = Effect.gen(function* () {
8891 latest_turn_id AS "latestTurnId",
8992 created_at AS "createdAt",
9093 updated_at AS "updatedAt",
94 archived_at AS "archivedAt",
9195 deleted_at AS "deletedAt"
9296 FROM projection_threads
9397 WHERE thread_id = ${threadId}
......@@ -111,6 +115,7 @@ const makeProjectionThreadRepository = Effect.gen(function* () {
111115 latest_turn_id AS "latestTurnId",
112116 created_at AS "createdAt",
113117 updated_at AS "updatedAt",
118 archived_at AS "archivedAt",
114119 deleted_at AS "deletedAt"
115120 FROM projection_threads
116121 WHERE project_id = ${projectId}
apps/server/src/persistence/Migrations.ts+4
......@@ -29,6 +29,8 @@ import Migration0013 from "./Migrations/013_ProjectionThreadProposedPlans.ts";
2929import Migration0014 from "./Migrations/014_ProjectionThreadProposedPlanImplementation.ts";
3030import Migration0015 from "./Migrations/015_ProjectionTurnsSourceProposedPlan.ts";
3131import Migration0016 from "./Migrations/016_CanonicalizeModelSelections.ts";
32import Migration0017 from "./Migrations/017_ProjectionThreadsArchivedAt.ts";
33import Migration0018 from "./Migrations/018_ProjectionThreadsArchivedAtIndex.ts";
3234
3335/**
3436 * Migration loader with all migrations defined inline.
......@@ -57,6 +59,8 @@ export const migrationEntries = [
5759 [14, "ProjectionThreadProposedPlanImplementation", Migration0014],
5860 [15, "ProjectionTurnsSourceProposedPlan", Migration0015],
5961 [16, "CanonicalizeModelSelections", Migration0016],
62 [17, "ProjectionThreadsArchivedAt", Migration0017],
63 [18, "ProjectionThreadsArchivedAtIndex", Migration0018],
6064] as const;
6165
6266export const makeMigrationLoader = (throughId?: number) =>
apps/server/src/persistence/Migrations/016_CanonicalizeModelSelections.test.ts+27-3
......@@ -128,6 +128,20 @@ layer("016_CanonicalizeModelSelections", (it) => {
128128 '{"projectId":"project-2","title":"Fallback Project","workspaceRoot":"/tmp/project-2","defaultModel":"claude-opus-4-6","defaultModelOptions":{"codex":{"reasoningEffort":"low"}},"scripts":[],"createdAt":"2026-01-01T00:00:00.000Z","updatedAt":"2026-01-01T00:00:00.000Z"}',
129129 '{}'
130130 ),
131 (
132 'event-project-created-null-model',
133 'project',
134 'project-3',
135 1,
136 'project.created',
137 '2026-01-01T00:00:00.000Z',
138 'command-project-created-null-model',
139 NULL,
140 'correlation-project-created-null-model',
141 'user',
142 '{"projectId":"project-3","title":"Null Model Project","workspaceRoot":"/tmp/project-3","defaultModel":null,"scripts":[],"createdAt":"2026-01-01T00:00:00.000Z","updatedAt":"2026-01-01T00:00:00.000Z"}',
143 '{}'
144 ),
131145 (
132146 'event-thread-created',
133147 'thread',
......@@ -284,6 +298,16 @@ layer("016_CanonicalizeModelSelections", (it) => {
284298 });
285299
286300 assert.deepStrictEqual(JSON.parse(eventRows[2]!.payloadJson), {
301 projectId: "project-3",
302 title: "Null Model Project",
303 workspaceRoot: "/tmp/project-3",
304 defaultModelSelection: null,
305 scripts: [],
306 createdAt: "2026-01-01T00:00:00.000Z",
307 updatedAt: "2026-01-01T00:00:00.000Z",
308 });
309
310 assert.deepStrictEqual(JSON.parse(eventRows[3]!.payloadJson), {
287311 threadId: "thread-1",
288312 projectId: "project-1",
289313 title: "Thread",
......@@ -303,7 +327,7 @@ layer("016_CanonicalizeModelSelections", (it) => {
303327 updatedAt: "2026-01-01T00:00:00.000Z",
304328 });
305329
306 assert.deepStrictEqual(JSON.parse(eventRows[3]!.payloadJson), {
330 assert.deepStrictEqual(JSON.parse(eventRows[4]!.payloadJson), {
307331 threadId: "thread-2",
308332 projectId: "project-1",
309333 title: "Fallback Thread",
......@@ -322,7 +346,7 @@ layer("016_CanonicalizeModelSelections", (it) => {
322346 updatedAt: "2026-01-01T00:00:00.000Z",
323347 });
324348
325 assert.deepStrictEqual(JSON.parse(eventRows[4]!.payloadJson), {
349 assert.deepStrictEqual(JSON.parse(eventRows[5]!.payloadJson), {
326350 threadId: "thread-1",
327351 turnId: "turn-1",
328352 input: "hi",
......@@ -336,7 +360,7 @@ layer("016_CanonicalizeModelSelections", (it) => {
336360 deliveryMode: "buffered",
337361 });
338362
339 assert.deepStrictEqual(JSON.parse(eventRows[5]!.payloadJson), {
363 assert.deepStrictEqual(JSON.parse(eventRows[6]!.payloadJson), {
340364 threadId: "thread-3",
341365 projectId: "project-1",
342366 title: "Ancient Thread",
apps/server/src/persistence/Migrations/017_ProjectionThreadsArchivedAt.ts created+18
......@@ -0,0 +1,18 @@
1import * as Effect from "effect/Effect";
2import * as SqlClient from "effect/unstable/sql/SqlClient";
3
4export default Effect.gen(function* () {
5 const sql = yield* SqlClient.SqlClient;
6 const columns = yield* sql<{ readonly name: string }>`
7 PRAGMA table_info(projection_threads)
8 `;
9
10 if (columns.some((column) => column.name === "archived_at")) {
11 return;
12 }
13
14 yield* sql`
15 ALTER TABLE projection_threads
16 ADD COLUMN archived_at TEXT
17 `;
18});
apps/server/src/persistence/Migrations/018_ProjectionThreadsArchivedAtIndex.ts created+11
......@@ -0,0 +1,11 @@
1import * as Effect from "effect/Effect";
2import * as SqlClient from "effect/unstable/sql/SqlClient";
3
4export default Effect.gen(function* () {
5 const sql = yield* SqlClient.SqlClient;
6
7 yield* sql`
8 CREATE INDEX IF NOT EXISTS idx_projection_threads_project_archived_at
9 ON projection_threads(project_id, archived_at)
10 `;
11});
apps/server/src/persistence/Services/ProjectionThreads.ts+1
......@@ -32,6 +32,7 @@ export const ProjectionThread = Schema.Struct({
3232 latestTurnId: Schema.NullOr(TurnId),
3333 createdAt: IsoDateTime,
3434 updatedAt: IsoDateTime,
35 archivedAt: Schema.NullOr(IsoDateTime),
3536 deletedAt: Schema.NullOr(IsoDateTime),
3637});
3738export type ProjectionThread = typeof ProjectionThread.Type;
apps/server/src/terminal/Layers/Manager.test.ts+3-3
......@@ -672,9 +672,9 @@ describe("TerminalManager", () => {
672672 ).toBe(true);
673673 } else {
674674 expect(
675 ptyAdapter.spawnInputs.some((input) =>
676 ["/bin/zsh", "/bin/bash", "/bin/sh", "zsh", "bash", "sh"].includes(input.shell),
677 ),
675 ptyAdapter.spawnInputs
676 .slice(1)
677 .some((input) => input.shell !== "/definitely/missing-shell"),
678678 ).toBe(true);
679679 }
680680
apps/web/src/components/AppSidebarLayout.tsx created+49
......@@ -0,0 +1,49 @@
1import { useEffect, type ReactNode } from "react";
2import { useNavigate } from "@tanstack/react-router";
3
4import ThreadSidebar from "./Sidebar";
5import { Sidebar, SidebarProvider, SidebarRail } from "./ui/sidebar";
6
7const THREAD_SIDEBAR_WIDTH_STORAGE_KEY = "chat_thread_sidebar_width";
8const THREAD_SIDEBAR_MIN_WIDTH = 13 * 16;
9const THREAD_MAIN_CONTENT_MIN_WIDTH = 40 * 16;
10
11export function AppSidebarLayout({ children }: { children: ReactNode }) {
12 const navigate = useNavigate();
13
14 useEffect(() => {
15 const onMenuAction = window.desktopBridge?.onMenuAction;
16 if (typeof onMenuAction !== "function") {
17 return;
18 }
19
20 const unsubscribe = onMenuAction((action) => {
21 if (action !== "open-settings") return;
22 void navigate({ to: "/settings" });
23 });
24
25 return () => {
26 unsubscribe?.();
27 };
28 }, [navigate]);
29
30 return (
31 <SidebarProvider defaultOpen>
32 <Sidebar
33 side="left"
34 collapsible="offcanvas"
35 className="border-r border-border bg-card text-foreground"
36 resizable={{
37 minWidth: THREAD_SIDEBAR_MIN_WIDTH,
38 shouldAcceptWidth: ({ nextWidth, wrapper }) =>
39 wrapper.clientWidth - nextWidth >= THREAD_MAIN_CONTENT_MIN_WIDTH,
40 storageKey: THREAD_SIDEBAR_WIDTH_STORAGE_KEY,
41 }}
42 >
43 <ThreadSidebar />
44 <SidebarRail />
45 </Sidebar>
46 {children}
47 </SidebarProvider>
48 );
49}
apps/web/src/components/ChatView.browser.tsx+2
......@@ -258,6 +258,7 @@ function createSnapshotForTargetUser(options: {
258258 latestTurn: null,
259259 createdAt: NOW_ISO,
260260 updatedAt: NOW_ISO,
261 archivedAt: null,
261262 deletedAt: null,
262263 messages,
263264 activities: [],
......@@ -315,6 +316,7 @@ function addThreadToSnapshot(
315316 latestTurn: null,
316317 createdAt: NOW_ISO,
317318 updatedAt: NOW_ISO,
319 archivedAt: null,
318320 deletedAt: null,
319321 messages: [],
320322 activities: [],
apps/web/src/components/ChatView.logic.ts+1
......@@ -32,6 +32,7 @@ export function buildLocalDraftThread(
3232 messages: [],
3333 error,
3434 createdAt: draftThread.createdAt,
35 archivedAt: null,
3536 latestTurn: null,
3637 lastVisitedAt: draftThread.createdAt,
3738 branch: draftThread.branch,
apps/web/src/components/KeybindingsToast.browser.tsx+1
......@@ -102,6 +102,7 @@ function createMinimalSnapshot(): OrchestrationReadModel {
102102 latestTurn: null,
103103 createdAt: NOW_ISO,
104104 updatedAt: NOW_ISO,
105 archivedAt: null,
105106 deletedAt: null,
106107 messages: [
107108 {
apps/web/src/components/ProjectFavicon.tsx created+49
......@@ -0,0 +1,49 @@
1import { FolderIcon } from "lucide-react";
2import { useState } from "react";
3
4function getServerHttpOrigin(): string {
5 const bridgeUrl = window.desktopBridge?.getWsUrl();
6 const envUrl = import.meta.env.VITE_WS_URL as string | undefined;
7 const wsUrl =
8 bridgeUrl && bridgeUrl.length > 0
9 ? bridgeUrl
10 : envUrl && envUrl.length > 0
11 ? envUrl
12 : `${window.location.protocol === "https:" ? "wss" : "ws"}://${window.location.hostname}:${window.location.port}`;
13 // Parse to extract just the origin, dropping path/query (e.g. ?token=…)
14 const httpUrl = wsUrl.replace(/^wss:/, "https:").replace(/^ws:/, "http:");
15 try {
16 return new URL(httpUrl).origin;
17 } catch {
18 return httpUrl;
19 }
20}
21
22const serverHttpOrigin = getServerHttpOrigin();
23
24const loadedProjectFaviconSrcs = new Set<string>();
25
26export function ProjectFavicon({ cwd, className }: { cwd: string; className?: string }) {
27 const src = `${serverHttpOrigin}/api/project-favicon?cwd=${encodeURIComponent(cwd)}`;
28 const [status, setStatus] = useState<"loading" | "loaded" | "error">(() =>
29 loadedProjectFaviconSrcs.has(src) ? "loaded" : "loading",
30 );
31
32 return (
33 <>
34 {status !== "loaded" ? (
35 <FolderIcon className={`size-3.5 shrink-0 text-muted-foreground/50 ${className ?? ""}`} />
36 ) : null}
37 <img
38 src={src}
39 alt=""
40 className={`size-3.5 shrink-0 rounded-sm object-contain ${status === "loaded" ? "" : "hidden"} ${className ?? ""}`}
41 onLoad={() => {
42 loadedProjectFaviconSrcs.add(src);
43 setStatus("loaded");
44 }}
45 onError={() => setStatus("error")}
46 />
47 </>
48 );
49}
apps/web/src/components/Sidebar.logic.test.ts+38
......@@ -380,6 +380,7 @@ function makeThread(overrides: Partial<Thread> = {}): Thread {
380380 proposedPlans: [],
381381 error: null,
382382 createdAt: "2026-03-09T10:00:00.000Z",
383 archivedAt: null,
383384 updatedAt: "2026-03-09T10:00:00.000Z",
384385 latestTurn: null,
385386 branch: null,
......@@ -697,6 +698,43 @@ describe("sortProjectsForSidebar", () => {
697698 ]);
698699 });
699700
701 it("ignores archived threads when sorting projects", () => {
702 const sorted = sortProjectsForSidebar(
703 [
704 makeProject({
705 id: ProjectId.makeUnsafe("project-1"),
706 name: "Visible project",
707 updatedAt: "2026-03-09T10:01:00.000Z",
708 }),
709 makeProject({
710 id: ProjectId.makeUnsafe("project-2"),
711 name: "Archived-only project",
712 updatedAt: "2026-03-09T10:00:00.000Z",
713 }),
714 ],
715 [
716 makeThread({
717 id: ThreadId.makeUnsafe("thread-visible"),
718 projectId: ProjectId.makeUnsafe("project-1"),
719 updatedAt: "2026-03-09T10:02:00.000Z",
720 archivedAt: null,
721 }),
722 makeThread({
723 id: ThreadId.makeUnsafe("thread-archived"),
724 projectId: ProjectId.makeUnsafe("project-2"),
725 updatedAt: "2026-03-09T10:10:00.000Z",
726 archivedAt: "2026-03-09T10:11:00.000Z",
727 }),
728 ].filter((thread) => thread.archivedAt === null),
729 "updated_at",
730 );
731
732 expect(sorted.map((project) => project.id)).toEqual([
733 ProjectId.makeUnsafe("project-1"),
734 ProjectId.makeUnsafe("project-2"),
735 ]);
736 });
737
700738 it("returns the project timestamp when no threads are present", () => {
701739 const timestamp = getProjectSortTimestamp(
702740 makeProject({ updatedAt: "2026-03-09T10:10:00.000Z" }),
apps/web/src/components/Sidebar.tsx+336-408
......@@ -1,5 +1,5 @@
11import {
2 ArrowLeftIcon,
2 ArchiveIcon,
33 ArrowUpDownIcon,
44 ChevronRightIcon,
55 FolderIcon,
......@@ -11,6 +11,7 @@ import {
1111 TerminalIcon,
1212 TriangleAlertIcon,
1313} from "lucide-react";
14import { ProjectFavicon } from "./ProjectFavicon";
1415import { autoAnimate } from "@formkit/auto-animate";
1516import { useCallback, useEffect, useMemo, useRef, useState, type MouseEvent } from "react";
1617import {
......@@ -36,7 +37,7 @@ import {
3637 type GitStatusResult,
3738 type ResolvedKeybindingsConfig,
3839} from "@t3tools/contracts";
39import { useMutation, useQueries, useQuery, useQueryClient } from "@tanstack/react-query";
40import { useQueries, useQuery } from "@tanstack/react-query";
4041import { useLocation, useNavigate, useParams } from "@tanstack/react-router";
4142import {
4243 type SidebarProjectSortOrder,
......@@ -48,13 +49,16 @@ import { isLinuxPlatform, isMacPlatform, newCommandId, newProjectId } from "../l
4849import { useStore } from "../store";
4950import { shortcutLabelForCommand } from "../keybindings";
5051import { derivePendingApprovals, derivePendingUserInputs } from "../session-logic";
51import { gitRemoveWorktreeMutationOptions, gitStatusQueryOptions } from "../lib/gitReactQuery";
52import { gitStatusQueryOptions } from "../lib/gitReactQuery";
5253import { serverConfigQueryOptions } from "../lib/serverReactQuery";
5354import { readNativeApi } from "../nativeApi";
5455import { useComposerDraftStore } from "../composerDraftStore";
5556import { useHandleNewThread } from "../hooks/useHandleNewThread";
57import { useThreadActions } from "../hooks/useThreadActions";
5658import { selectThreadTerminalState, useTerminalStateStore } from "../terminalStateStore";
5759import { toastManager } from "./ui/toast";
60import { formatRelativeTimeLabel } from "../timestampFormat";
61import { SettingsSidebarNav } from "./settings/SettingsSidebarNav";
5862import {
5963 getArm64IntelBuildWarningDescription,
6064 getDesktopUpdateActionError,
......@@ -87,10 +91,8 @@ import {
8791 SidebarTrigger,
8892} from "./ui/sidebar";
8993import { useThreadSelectionStore } from "../threadSelectionStore";
90import { formatWorktreePathForDisplay, getOrphanedWorktreePathForThread } from "../worktreeCleanup";
9194import { isNonEmpty as isNonEmptyString } from "effect/String";
9295import {
93 getFallbackThreadIdAfterDelete,
9496 getVisibleThreadsForProject,
9597 resolveProjectStatusIndicator,
9698 resolveSidebarNewThreadEnvMode,
......@@ -118,18 +120,6 @@ const SIDEBAR_LIST_ANIMATION_OPTIONS = {
118120 duration: 180,
119121 easing: "ease-out",
120122} as const;
121const loadedProjectFaviconSrcs = new Set<string>();
122
123function formatRelativeTime(iso: string): string {
124 const diff = Date.now() - new Date(iso).getTime();
125 const minutes = Math.floor(diff / 60_000);
126 if (minutes < 1) return "just now";
127 if (minutes < 60) return `${minutes}m ago`;
128 const hours = Math.floor(minutes / 60);
129 if (hours < 24) return `${hours}h ago`;
130 return `${Math.floor(hours / 24)}d ago`;
131}
132
133123interface TerminalStatusIndicator {
134124 label: "Terminal process running";
135125 colorClass: string;
......@@ -204,54 +194,6 @@ function T3Wordmark() {
204194 );
205195}
206196
207/**
208 * Derives the server's HTTP origin (scheme + host + port) from the same
209 * sources WsTransport uses, converting ws(s) to http(s).
210 */
211function getServerHttpOrigin(): string {
212 const bridgeUrl = window.desktopBridge?.getWsUrl();
213 const envUrl = import.meta.env.VITE_WS_URL as string | undefined;
214 const wsUrl =
215 bridgeUrl && bridgeUrl.length > 0
216 ? bridgeUrl
217 : envUrl && envUrl.length > 0
218 ? envUrl
219 : `${window.location.protocol === "https:" ? "wss" : "ws"}://${window.location.hostname}:${window.location.port}`;
220 // Parse to extract just the origin, dropping path/query (e.g. ?token=…)
221 const httpUrl = wsUrl.replace(/^wss:/, "https:").replace(/^ws:/, "http:");
222 try {
223 return new URL(httpUrl).origin;
224 } catch {
225 return httpUrl;
226 }
227}
228
229const serverHttpOrigin = getServerHttpOrigin();
230
231function ProjectFavicon({ cwd }: { cwd: string }) {
232 const src = `${serverHttpOrigin}/api/project-favicon?cwd=${encodeURIComponent(cwd)}`;
233 const [status, setStatus] = useState<"loading" | "loaded" | "error">(() =>
234 loadedProjectFaviconSrcs.has(src) ? "loaded" : "loading",
235 );
236
237 if (status === "error") {
238 return <FolderIcon className="size-3.5 shrink-0 text-muted-foreground/50" />;
239 }
240
241 return (
242 <img
243 src={src}
244 alt=""
245 className={`size-3.5 shrink-0 rounded-sm object-contain ${status === "loading" ? "hidden" : ""}`}
246 onLoad={() => {
247 loadedProjectFaviconSrcs.add(src);
248 setStatus("loaded");
249 }}
250 onError={() => setStatus("error")}
251 />
252 );
253}
254
255197type SortableProjectHandleProps = Pick<
256198 ReturnType<typeof useSortable>,
257199 "attributes" | "listeners" | "setActivatorNodeRef"
......@@ -372,18 +314,16 @@ export default function Sidebar() {
372314 (store) => store.getDraftThreadByProjectId,
373315 );
374316 const terminalStateByThreadId = useTerminalStateStore((state) => state.terminalStateByThreadId);
375 const clearTerminalState = useTerminalStateStore((state) => state.clearTerminalState);
376317 const clearProjectDraftThreadId = useComposerDraftStore(
377318 (store) => store.clearProjectDraftThreadId,
378319 );
379 const clearProjectDraftThreadById = useComposerDraftStore(
380 (store) => store.clearProjectDraftThreadById,
381 );
382320 const navigate = useNavigate();
383 const isOnSettings = useLocation({ select: (loc) => loc.pathname === "/settings" });
321 const pathname = useLocation({ select: (loc) => loc.pathname });
322 const isOnSettings = pathname.startsWith("/settings");
384323 const appSettings = useSettings();
385324 const { updateSettings } = useUpdateSettings();
386325 const { handleNewThread } = useHandleNewThread();
326 const { archiveThread, deleteThread } = useThreadActions();
387327 const routeThreadId = useParams({
388328 strict: false,
389329 select: (params) => (params.threadId ? ThreadId.makeUnsafe(params.threadId) : null),
......@@ -392,8 +332,6 @@ export default function Sidebar() {
392332 ...serverConfigQueryOptions(),
393333 select: (config) => config.keybindings,
394334 });
395 const queryClient = useQueryClient();
396 const removeWorktreeMutation = useMutation(gitRemoveWorktreeMutationOptions({ queryClient }));
397335 const [addingProject, setAddingProject] = useState(false);
398336 const [newCwd, setNewCwd] = useState("");
399337 const [isPickingFolder, setIsPickingFolder] = useState(false);
......@@ -402,6 +340,8 @@ export default function Sidebar() {
402340 const addProjectInputRef = useRef<HTMLInputElement | null>(null);
403341 const [renamingThreadId, setRenamingThreadId] = useState<ThreadId | null>(null);
404342 const [renamingTitle, setRenamingTitle] = useState("");
343 const [hoveredThreadId, setHoveredThreadId] = useState<ThreadId | null>(null);
344 const [confirmingArchiveThreadId, setConfirmingArchiveThreadId] = useState<ThreadId | null>(null);
405345 const [expandedThreadListsByProject, setExpandedThreadListsByProject] = useState<
406346 ReadonlySet<ProjectId>
407347 >(() => new Set());
......@@ -493,10 +433,25 @@ export default function Sidebar() {
493433 });
494434 }, []);
495435
436 const attemptArchiveThread = useCallback(
437 async (threadId: ThreadId) => {
438 try {
439 await archiveThread(threadId);
440 } catch (error) {
441 toastManager.add({
442 type: "error",
443 title: "Failed to archive thread",
444 description: error instanceof Error ? error.message : "An error occurred.",
445 });
446 }
447 },
448 [archiveThread],
449 );
450
496451 const focusMostRecentThreadForProject = useCallback(
497452 (projectId: ProjectId) => {
498453 const latestThread = sortThreadsForSidebar(
499 threads.filter((thread) => thread.projectId === projectId),
454 threads.filter((thread) => thread.projectId === projectId && thread.archivedAt === null),
500455 appSettings.sidebarThreadSortOrder,
501456 )[0];
502457 if (!latestThread) return;
......@@ -627,7 +582,10 @@ export default function Sidebar() {
627582
628583 const trimmed = newTitle.trim();
629584 if (trimmed.length === 0) {
630 toastManager.add({ type: "warning", title: "Thread title cannot be empty" });
585 toastManager.add({
586 type: "warning",
587 title: "Thread title cannot be empty",
588 });
631589 finishRename();
632590 return;
633591 }
......@@ -659,129 +617,9 @@ export default function Sidebar() {
659617 [],
660618 );
661619
662 /**
663 * Delete a single thread: stop session, close terminal, dispatch delete,
664 * clean up drafts/state, and optionally remove orphaned worktree.
665 * Callers handle thread-level confirmation; this still prompts for worktree removal.
666 */
667 const deleteThread = useCallback(
668 async (
669 threadId: ThreadId,
670 opts: { deletedThreadIds?: ReadonlySet<ThreadId> } = {},
671 ): Promise<void> => {
672 const api = readNativeApi();
673 if (!api) return;
674 const thread = threads.find((t) => t.id === threadId);
675 if (!thread) return;
676 const threadProject = projects.find((project) => project.id === thread.projectId);
677 // When bulk-deleting, exclude the other threads being deleted so
678 // getOrphanedWorktreePathForThread correctly detects that no surviving
679 // threads will reference this worktree.
680 const deletedIds = opts.deletedThreadIds;
681 const survivingThreads =
682 deletedIds && deletedIds.size > 0
683 ? threads.filter((t) => t.id === threadId || !deletedIds.has(t.id))
684 : threads;
685 const orphanedWorktreePath = getOrphanedWorktreePathForThread(survivingThreads, threadId);
686 const displayWorktreePath = orphanedWorktreePath
687 ? formatWorktreePathForDisplay(orphanedWorktreePath)
688 : null;
689 const canDeleteWorktree = orphanedWorktreePath !== null && threadProject !== undefined;
690 const shouldDeleteWorktree =
691 canDeleteWorktree &&
692 (await api.dialogs.confirm(
693 [
694 "This thread is the only one linked to this worktree:",
695 displayWorktreePath ?? orphanedWorktreePath,
696 "",
697 "Delete the worktree too?",
698 ].join("\n"),
699 ));
700
701 if (thread.session && thread.session.status !== "closed") {
702 await api.orchestration
703 .dispatchCommand({
704 type: "thread.session.stop",
705 commandId: newCommandId(),
706 threadId,
707 createdAt: new Date().toISOString(),
708 })
709 .catch(() => undefined);
710 }
711
712 try {
713 await api.terminal.close({ threadId, deleteHistory: true });
714 } catch {
715 // Terminal may already be closed
716 }
717
718 const allDeletedIds = deletedIds ?? new Set<ThreadId>();
719 const shouldNavigateToFallback = routeThreadId === threadId;
720 const fallbackThreadId = getFallbackThreadIdAfterDelete({
721 threads,
722 deletedThreadId: threadId,
723 deletedThreadIds: allDeletedIds,
724 sortOrder: appSettings.sidebarThreadSortOrder,
725 });
726 await api.orchestration.dispatchCommand({
727 type: "thread.delete",
728 commandId: newCommandId(),
729 threadId,
730 });
731 clearComposerDraftForThread(threadId);
732 clearProjectDraftThreadById(thread.projectId, thread.id);
733 clearTerminalState(threadId);
734 if (shouldNavigateToFallback) {
735 if (fallbackThreadId) {
736 void navigate({
737 to: "/$threadId",
738 params: { threadId: fallbackThreadId },
739 replace: true,
740 });
741 } else {
742 void navigate({ to: "/", replace: true });
743 }
744 }
745
746 if (!shouldDeleteWorktree || !orphanedWorktreePath || !threadProject) {
747 return;
748 }
749
750 try {
751 await removeWorktreeMutation.mutateAsync({
752 cwd: threadProject.cwd,
753 path: orphanedWorktreePath,
754 force: true,
755 });
756 } catch (error) {
757 const message = error instanceof Error ? error.message : "Unknown error removing worktree.";
758 console.error("Failed to remove orphaned worktree after thread deletion", {
759 threadId,
760 projectCwd: threadProject.cwd,
761 worktreePath: orphanedWorktreePath,
762 error,
763 });
764 toastManager.add({
765 type: "error",
766 title: "Thread deleted, but worktree removal failed",
767 description: `Could not remove ${displayWorktreePath ?? orphanedWorktreePath}. ${message}`,
768 });
769 }
770 },
771 [
772 appSettings.sidebarThreadSortOrder,
773 clearComposerDraftForThread,
774 clearProjectDraftThreadById,
775 clearTerminalState,
776 navigate,
777 projects,
778 removeWorktreeMutation,
779 routeThreadId,
780 threads,
781 ],
782 );
783
784 const { copyToClipboard: copyThreadIdToClipboard } = useCopyToClipboard<{ threadId: ThreadId }>({
620 const { copyToClipboard: copyThreadIdToClipboard } = useCopyToClipboard<{
621 threadId: ThreadId;
622 }>({
785623 onCopy: (ctx) => {
786624 toastManager.add({
787625 type: "success",
......@@ -797,7 +635,9 @@ export default function Sidebar() {
797635 });
798636 },
799637 });
800 const { copyToClipboard: copyPathToClipboard } = useCopyToClipboard<{ path: string }>({
638 const { copyToClipboard: copyPathToClipboard } = useCopyToClipboard<{
639 path: string;
640 }>({
801641 onCopy: (ctx) => {
802642 toastManager.add({
803643 type: "success",
......@@ -1098,9 +938,13 @@ export default function Sidebar() {
1098938 suppressProjectClickAfterDragRef.current = false;
1099939 }, []);
1100940
941 const visibleThreads = useMemo(
942 () => threads.filter((thread) => thread.archivedAt === null),
943 [threads],
944 );
1101945 const sortedProjects = useMemo(
1102 () => sortProjectsForSidebar(projects, threads, appSettings.sidebarProjectSortOrder),
1103 [appSettings.sidebarProjectSortOrder, projects, threads],
946 () => sortProjectsForSidebar(projects, visibleThreads, appSettings.sidebarProjectSortOrder),
947 [appSettings.sidebarProjectSortOrder, projects, visibleThreads],
1104948 );
1105949 const isManualProjectSorting = appSettings.sidebarProjectSortOrder === "manual";
1106950
......@@ -1109,7 +953,7 @@ export default function Sidebar() {
1109953 dragHandleProps: SortableProjectHandleProps | null,
1110954 ) {
1111955 const projectThreads = sortThreadsForSidebar(
1112 threads.filter((thread) => thread.projectId === project.id),
956 visibleThreads.filter((thread) => thread.projectId === project.id),
1113957 appSettings.sidebarThreadSortOrder,
1114958 );
1115959 const projectStatus = resolveProjectStatusIndicator(
......@@ -1128,18 +972,22 @@ export default function Sidebar() {
1128972 ? (projectThreads.find((thread) => thread.id === activeThreadId) ?? null)
1129973 : null;
1130974 const shouldShowThreadPanel = project.expanded || pinnedCollapsedThread !== null;
1131 const { hasHiddenThreads, visibleThreads } = getVisibleThreadsForProject({
1132 threads: projectThreads,
1133 activeThreadId,
1134 isThreadListExpanded,
1135 previewLimit: THREAD_PREVIEW_LIMIT,
1136 });
975 const { hasHiddenThreads, visibleThreads: visibleProjectThreads } = getVisibleThreadsForProject(
976 {
977 threads: projectThreads,
978 activeThreadId,
979 isThreadListExpanded,
980 previewLimit: THREAD_PREVIEW_LIMIT,
981 },
982 );
1137983 const orderedProjectThreadIds = projectThreads.map((thread) => thread.id);
1138 const renderedThreads = pinnedCollapsedThread ? [pinnedCollapsedThread] : visibleThreads;
984 const renderedThreads = pinnedCollapsedThread ? [pinnedCollapsedThread] : visibleProjectThreads;
1139985 const renderThreadRow = (thread: (typeof projectThreads)[number]) => {
1140986 const isActive = routeThreadId === thread.id;
1141987 const isSelected = selectedThreadIds.has(thread.id);
1142988 const isHighlighted = isActive || isSelected;
989 const isThreadRunning =
990 thread.session?.status === "running" && thread.session.activeTurnId != null;
1143991 const threadStatus = resolveThreadStatusPill({
1144992 thread,
1145993 hasPendingApprovals: derivePendingApprovals(thread.activities).length > 0,
......@@ -1151,15 +999,26 @@ export default function Sidebar() {
1151999 );
11521000
11531001 return (
1154 <SidebarMenuSubItem key={thread.id} className="w-full" data-thread-item>
1002 <SidebarMenuSubItem
1003 key={thread.id}
1004 className="w-full"
1005 data-thread-item
1006 onMouseEnter={() => {
1007 setHoveredThreadId(thread.id);
1008 }}
1009 onMouseLeave={() => {
1010 setHoveredThreadId((current) => (current === thread.id ? null : current));
1011 setConfirmingArchiveThreadId((current) => (current === thread.id ? null : current));
1012 }}
1013 >
11551014 <SidebarMenuSubButton
11561015 render={<div role="button" tabIndex={0} />}
11571016 size="sm"
11581017 isActive={isActive}
1159 className={resolveThreadRowClassName({
1018 className={`${resolveThreadRowClassName({
11601019 isActive,
11611020 isSelected,
1162 })}
1021 })} relative`}
11631022 onClick={(event) => {
11641023 handleThreadClick(event, thread.id, orderedProjectThreadIds);
11651024 }}
......@@ -1177,6 +1036,7 @@ export default function Sidebar() {
11771036 }}
11781037 onContextMenu={(event) => {
11791038 event.preventDefault();
1039 setHoveredThreadId((current) => (current === thread.id ? null : current));
11801040 if (selectedThreadIds.size > 0 && selectedThreadIds.has(thread.id)) {
11811041 void handleMultiSelectContextMenu({
11821042 x: event.clientX,
......@@ -1273,15 +1133,86 @@ export default function Sidebar() {
12731133 />
12741134 </span>
12751135 )}
1276 <span
1277 className={`text-[10px] ${
1278 isHighlighted
1279 ? "text-foreground/72 dark:text-foreground/82"
1280 : "text-muted-foreground/40"
1281 }`}
1282 >
1283 {formatRelativeTime(thread.updatedAt ?? thread.createdAt)}
1284 </span>
1136 <div className="flex min-w-12 justify-end">
1137 {confirmingArchiveThreadId === thread.id && !isThreadRunning ? (
1138 <button
1139 type="button"
1140 data-thread-selection-safe
1141 aria-label={`Confirm archive ${thread.title}`}
1142 className="absolute top-1/2 right-1 inline-flex h-5 -translate-y-1/2 cursor-pointer items-center rounded-full bg-destructive/12 px-2 text-[10px] font-medium text-destructive transition-colors hover:bg-destructive/18 focus-visible:outline-hidden focus-visible:ring-1 focus-visible:ring-destructive/40"
1143 onPointerDown={(event) => {
1144 event.stopPropagation();
1145 }}
1146 onClick={(event) => {
1147 event.preventDefault();
1148 event.stopPropagation();
1149 setConfirmingArchiveThreadId((current) =>
1150 current === thread.id ? null : current,
1151 );
1152 setHoveredThreadId((current) => (current === thread.id ? null : current));
1153 void attemptArchiveThread(thread.id);
1154 }}
1155 >
1156 Confirm
1157 </button>
1158 ) : hoveredThreadId === thread.id && !isThreadRunning ? (
1159 appSettings.confirmThreadArchive ? (
1160 <button
1161 type="button"
1162 data-thread-selection-safe
1163 aria-label={`Archive ${thread.title}`}
1164 className="absolute top-1/2 right-1 inline-flex size-5 -translate-y-1/2 cursor-pointer items-center justify-center text-muted-foreground transition-colors hover:text-foreground focus-visible:outline-hidden focus-visible:ring-1 focus-visible:ring-ring"
1165 onPointerDown={(event) => {
1166 event.stopPropagation();
1167 }}
1168 onClick={(event) => {
1169 event.preventDefault();
1170 event.stopPropagation();
1171 setConfirmingArchiveThreadId(thread.id);
1172 }}
1173 >
1174 <ArchiveIcon className="size-3.5" />
1175 </button>
1176 ) : (
1177 <Tooltip>
1178 <TooltipTrigger
1179 render={
1180 <button
1181 type="button"
1182 data-thread-selection-safe
1183 aria-label={`Archive ${thread.title}`}
1184 className="absolute top-1/2 right-1 inline-flex size-5 -translate-y-1/2 cursor-pointer items-center justify-center text-muted-foreground transition-colors hover:text-foreground focus-visible:outline-hidden focus-visible:ring-1 focus-visible:ring-ring"
1185 onPointerDown={(event) => {
1186 event.stopPropagation();
1187 }}
1188 onClick={(event) => {
1189 event.preventDefault();
1190 event.stopPropagation();
1191 setHoveredThreadId((current) =>
1192 current === thread.id ? null : current,
1193 );
1194 void attemptArchiveThread(thread.id);
1195 }}
1196 >
1197 <ArchiveIcon className="size-3.5" />
1198 </button>
1199 }
1200 />
1201 <TooltipPopup side="top">Archive</TooltipPopup>
1202 </Tooltip>
1203 )
1204 ) : (
1205 <span
1206 className={`text-[10px] ${
1207 isHighlighted
1208 ? "text-foreground/72 dark:text-foreground/82"
1209 : "text-muted-foreground/40"
1210 }`}
1211 >
1212 {formatRelativeTimeLabel(thread.updatedAt ?? thread.createdAt)}
1213 </span>
1214 )}
1215 </div>
12851216 </div>
12861217 </SidebarMenuSubButton>
12871218 </SidebarMenuSubItem>
......@@ -1349,7 +1280,7 @@ export default function Sidebar() {
13491280 />
13501281 }
13511282 showOnHover
1352 className="top-1 right-1 size-5 rounded-md p-0 text-muted-foreground/70 hover:bg-secondary hover:text-foreground"
1283 className="top-1 right-1.5 size-5 rounded-md p-0 text-muted-foreground/70 hover:bg-secondary hover:text-foreground"
13531284 onClick={(event) => {
13541285 event.preventDefault();
13551286 event.stopPropagation();
......@@ -1658,202 +1589,199 @@ export default function Sidebar() {
16581589 </SidebarHeader>
16591590 )}
16601591
1661 <SidebarContent className="gap-0">
1662 {showArm64IntelBuildWarning && arm64IntelBuildWarningDescription ? (
1663 <SidebarGroup className="px-2 pt-2 pb-0">
1664 <Alert variant="warning" className="rounded-2xl border-warning/40 bg-warning/8">
1665 <TriangleAlertIcon />
1666 <AlertTitle>Intel build on Apple Silicon</AlertTitle>
1667 <AlertDescription>{arm64IntelBuildWarningDescription}</AlertDescription>
1668 {desktopUpdateButtonAction !== "none" ? (
1669 <AlertAction>
1670 <Button
1671 size="xs"
1672 variant="outline"
1673 disabled={desktopUpdateButtonDisabled}
1674 onClick={handleDesktopUpdateButtonClick}
1675 >
1676 {desktopUpdateButtonAction === "download"
1677 ? "Download ARM build"
1678 : "Install ARM build"}
1679 </Button>
1680 </AlertAction>
1681 ) : null}
1682 </Alert>
1683 </SidebarGroup>
1684 ) : null}
1685 <SidebarGroup className="px-2 py-2">
1686 <div className="mb-1 flex items-center justify-between px-2">
1687 <span className="text-[10px] font-medium uppercase tracking-wider text-muted-foreground/60">
1688 Projects
1689 </span>
1690 <div className="flex items-center gap-1">
1691 <ProjectSortMenu
1692 projectSortOrder={appSettings.sidebarProjectSortOrder}
1693 threadSortOrder={appSettings.sidebarThreadSortOrder}
1694 onProjectSortOrderChange={(sortOrder) => {
1695 updateSettings({ sidebarProjectSortOrder: sortOrder });
1696 }}
1697 onThreadSortOrderChange={(sortOrder) => {
1698 updateSettings({ sidebarThreadSortOrder: sortOrder });
1699 }}
1700 />
1701 <Tooltip>
1702 <TooltipTrigger
1703 render={
1592 {isOnSettings ? (
1593 <SettingsSidebarNav pathname={pathname} />
1594 ) : (
1595 <>
1596 <SidebarContent className="gap-0">
1597 {showArm64IntelBuildWarning && arm64IntelBuildWarningDescription ? (
1598 <SidebarGroup className="px-2 pt-2 pb-0">
1599 <Alert variant="warning" className="rounded-2xl border-warning/40 bg-warning/8">
1600 <TriangleAlertIcon />
1601 <AlertTitle>Intel build on Apple Silicon</AlertTitle>
1602 <AlertDescription>{arm64IntelBuildWarningDescription}</AlertDescription>
1603 {desktopUpdateButtonAction !== "none" ? (
1604 <AlertAction>
1605 <Button
1606 size="xs"
1607 variant="outline"
1608 disabled={desktopUpdateButtonDisabled}
1609 onClick={handleDesktopUpdateButtonClick}
1610 >
1611 {desktopUpdateButtonAction === "download"
1612 ? "Download ARM build"
1613 : "Install ARM build"}
1614 </Button>
1615 </AlertAction>
1616 ) : null}
1617 </Alert>
1618 </SidebarGroup>
1619 ) : null}
1620 <SidebarGroup className="px-2 py-2">
1621 <div className="mb-1 flex items-center justify-between pl-2 pr-1.5">
1622 <span className="text-[10px] font-medium uppercase tracking-wider text-muted-foreground/60">
1623 Projects
1624 </span>
1625 <div className="flex items-center gap-1">
1626 <ProjectSortMenu
1627 projectSortOrder={appSettings.sidebarProjectSortOrder}
1628 threadSortOrder={appSettings.sidebarThreadSortOrder}
1629 onProjectSortOrderChange={(sortOrder) => {
1630 updateSettings({ sidebarProjectSortOrder: sortOrder });
1631 }}
1632 onThreadSortOrderChange={(sortOrder) => {
1633 updateSettings({ sidebarThreadSortOrder: sortOrder });
1634 }}
1635 />
1636 <Tooltip>
1637 <TooltipTrigger
1638 render={
1639 <button
1640 type="button"
1641 aria-label={
1642 shouldShowProjectPathEntry ? "Cancel add project" : "Add project"
1643 }
1644 aria-pressed={shouldShowProjectPathEntry}
1645 className="inline-flex size-5 cursor-pointer items-center justify-center rounded-md text-muted-foreground/60 transition-colors hover:bg-accent hover:text-foreground"
1646 onClick={handleStartAddProject}
1647 />
1648 }
1649 >
1650 <PlusIcon
1651 className={`size-3.5 transition-transform duration-150 ${
1652 shouldShowProjectPathEntry ? "rotate-45" : "rotate-0"
1653 }`}
1654 />
1655 </TooltipTrigger>
1656 <TooltipPopup side="right">
1657 {shouldShowProjectPathEntry ? "Cancel add project" : "Add project"}
1658 </TooltipPopup>
1659 </Tooltip>
1660 </div>
1661 </div>
1662
1663 {shouldShowProjectPathEntry && (
1664 <div className="mb-2 px-1">
1665 {isElectron && (
17041666 <button
17051667 type="button"
1706 aria-label={shouldShowProjectPathEntry ? "Cancel add project" : "Add project"}
1707 aria-pressed={shouldShowProjectPathEntry}
1708 className="inline-flex size-5 cursor-pointer items-center justify-center rounded-md text-muted-foreground/60 transition-colors hover:bg-accent hover:text-foreground"
1709 onClick={handleStartAddProject}
1668 className="mb-1.5 flex w-full items-center justify-center gap-2 rounded-md border border-border bg-secondary py-1.5 text-xs text-foreground/80 transition-colors duration-150 hover:bg-accent hover:text-foreground disabled:cursor-not-allowed disabled:opacity-60"
1669 onClick={() => void handlePickFolder()}
1670 disabled={isPickingFolder || isAddingProject}
1671 >
1672 <FolderIcon className="size-3.5" />
1673 {isPickingFolder ? "Picking folder..." : "Browse for folder"}
1674 </button>
1675 )}
1676 <div className="flex gap-1.5">
1677 <input
1678 ref={addProjectInputRef}
1679 className={`min-w-0 flex-1 rounded-md border bg-secondary px-2 py-1 font-mono text-xs text-foreground placeholder:text-muted-foreground/40 focus:outline-none ${
1680 addProjectError
1681 ? "border-red-500/70 focus:border-red-500"
1682 : "border-border focus:border-ring"
1683 }`}
1684 placeholder="/path/to/project"
1685 value={newCwd}
1686 onChange={(event) => {
1687 setNewCwd(event.target.value);
1688 setAddProjectError(null);
1689 }}
1690 onKeyDown={(event) => {
1691 if (event.key === "Enter") handleAddProject();
1692 if (event.key === "Escape") {
1693 setAddingProject(false);
1694 setAddProjectError(null);
1695 }
1696 }}
1697 autoFocus
17101698 />
1711 }
1712 >
1713 <PlusIcon
1714 className={`size-3.5 transition-transform duration-150 ${
1715 shouldShowProjectPathEntry ? "rotate-45" : "rotate-0"
1716 }`}
1717 />
1718 </TooltipTrigger>
1719 <TooltipPopup side="right">
1720 {shouldShowProjectPathEntry ? "Cancel add project" : "Add project"}
1721 </TooltipPopup>
1722 </Tooltip>
1723 </div>
1724 </div>
1699 <button
1700 type="button"
1701 className="shrink-0 rounded-md bg-primary px-2.5 py-1 text-xs font-medium text-primary-foreground transition-colors duration-150 hover:bg-primary/90 disabled:opacity-60"
1702 onClick={handleAddProject}
1703 disabled={!canAddProject}
1704 >
1705 {isAddingProject ? "Adding..." : "Add"}
1706 </button>
1707 </div>
1708 {addProjectError && (
1709 <p className="mt-1 px-0.5 text-[11px] leading-tight text-red-400">
1710 {addProjectError}
1711 </p>
1712 )}
1713 <div className="mt-1.5 px-0.5">
1714 <button
1715 type="button"
1716 className="text-[11px] text-muted-foreground/50 transition-colors hover:text-muted-foreground"
1717 onClick={() => {
1718 setAddingProject(false);
1719 setAddProjectError(null);
1720 }}
1721 >
1722 Cancel
1723 </button>
1724 </div>
1725 </div>
1726 )}
17251727
1726 {shouldShowProjectPathEntry && (
1727 <div className="mb-2 px-1">
1728 {isElectron && (
1729 <button
1730 type="button"
1731 className="mb-1.5 flex w-full items-center justify-center gap-2 rounded-md border border-border bg-secondary py-1.5 text-xs text-foreground/80 transition-colors duration-150 hover:bg-accent hover:text-foreground disabled:cursor-not-allowed disabled:opacity-60"
1732 onClick={() => void handlePickFolder()}
1733 disabled={isPickingFolder || isAddingProject}
1728 {isManualProjectSorting ? (
1729 <DndContext
1730 sensors={projectDnDSensors}
1731 collisionDetection={projectCollisionDetection}
1732 modifiers={[restrictToVerticalAxis, restrictToFirstScrollableAncestor]}
1733 onDragStart={handleProjectDragStart}
1734 onDragEnd={handleProjectDragEnd}
1735 onDragCancel={handleProjectDragCancel}
17341736 >
1735 <FolderIcon className="size-3.5" />
1736 {isPickingFolder ? "Picking folder..." : "Browse for folder"}
1737 </button>
1737 <SidebarMenu>
1738 <SortableContext
1739 items={sortedProjects.map((project) => project.id)}
1740 strategy={verticalListSortingStrategy}
1741 >
1742 {sortedProjects.map((project) => (
1743 <SortableProjectItem key={project.id} projectId={project.id}>
1744 {(dragHandleProps) => renderProjectItem(project, dragHandleProps)}
1745 </SortableProjectItem>
1746 ))}
1747 </SortableContext>
1748 </SidebarMenu>
1749 </DndContext>
1750 ) : (
1751 <SidebarMenu ref={attachProjectListAutoAnimateRef}>
1752 {sortedProjects.map((project) => (
1753 <SidebarMenuItem key={project.id} className="rounded-md">
1754 {renderProjectItem(project, null)}
1755 </SidebarMenuItem>
1756 ))}
1757 </SidebarMenu>
17381758 )}
1739 <div className="flex gap-1.5">
1740 <input
1741 ref={addProjectInputRef}
1742 className={`min-w-0 flex-1 rounded-md border bg-secondary px-2 py-1 font-mono text-xs text-foreground placeholder:text-muted-foreground/40 focus:outline-none ${
1743 addProjectError
1744 ? "border-red-500/70 focus:border-red-500"
1745 : "border-border focus:border-ring"
1746 }`}
1747 placeholder="/path/to/project"
1748 value={newCwd}
1749 onChange={(event) => {
1750 setNewCwd(event.target.value);
1751 setAddProjectError(null);
1752 }}
1753 onKeyDown={(event) => {
1754 if (event.key === "Enter") handleAddProject();
1755 if (event.key === "Escape") {
1756 setAddingProject(false);
1757 setAddProjectError(null);
1758 }
1759 }}
1760 autoFocus
1761 />
1762 <button
1763 type="button"
1764 className="shrink-0 rounded-md bg-primary px-2.5 py-1 text-xs font-medium text-primary-foreground transition-colors duration-150 hover:bg-primary/90 disabled:opacity-60"
1765 onClick={handleAddProject}
1766 disabled={!canAddProject}
1767 >
1768 {isAddingProject ? "Adding..." : "Add"}
1769 </button>
1770 </div>
1771 {addProjectError && (
1772 <p className="mt-1 px-0.5 text-[11px] leading-tight text-red-400">
1773 {addProjectError}
1774 </p>
1759
1760 {projects.length === 0 && !shouldShowProjectPathEntry && (
1761 <div className="px-2 pt-4 text-center text-xs text-muted-foreground/60">
1762 No projects yet
1763 </div>
17751764 )}
1776 <div className="mt-1.5 px-0.5">
1777 <button
1778 type="button"
1779 className="text-[11px] text-muted-foreground/50 transition-colors hover:text-muted-foreground"
1780 onClick={() => {
1781 setAddingProject(false);
1782 setAddProjectError(null);
1783 }}
1784 >
1785 Cancel
1786 </button>
1787 </div>
1788 </div>
1789 )}
1790
1791 {isManualProjectSorting ? (
1792 <DndContext
1793 sensors={projectDnDSensors}
1794 collisionDetection={projectCollisionDetection}
1795 modifiers={[restrictToVerticalAxis, restrictToFirstScrollableAncestor]}
1796 onDragStart={handleProjectDragStart}
1797 onDragEnd={handleProjectDragEnd}
1798 onDragCancel={handleProjectDragCancel}
1799 >
1800 <SidebarMenu>
1801 <SortableContext
1802 items={sortedProjects.map((project) => project.id)}
1803 strategy={verticalListSortingStrategy}
1765 </SidebarGroup>
1766 </SidebarContent>
1767
1768 <SidebarSeparator />
1769 <SidebarFooter className="p-2">
1770 <SidebarMenu>
1771 <SidebarMenuItem>
1772 <SidebarMenuButton
1773 size="sm"
1774 className="gap-2 px-2 py-1.5 text-muted-foreground/70 hover:bg-accent hover:text-foreground"
1775 onClick={() => void navigate({ to: "/settings" })}
18041776 >
1805 {sortedProjects.map((project) => (
1806 <SortableProjectItem key={project.id} projectId={project.id}>
1807 {(dragHandleProps) => renderProjectItem(project, dragHandleProps)}
1808 </SortableProjectItem>
1809 ))}
1810 </SortableContext>
1811 </SidebarMenu>
1812 </DndContext>
1813 ) : (
1814 <SidebarMenu ref={attachProjectListAutoAnimateRef}>
1815 {sortedProjects.map((project) => (
1816 <SidebarMenuItem key={project.id} className="rounded-md">
1817 {renderProjectItem(project, null)}
1818 </SidebarMenuItem>
1819 ))}
1777 <SettingsIcon className="size-3.5" />
1778 <span className="text-xs">Settings</span>
1779 </SidebarMenuButton>
1780 </SidebarMenuItem>
18201781 </SidebarMenu>
1821 )}
1822
1823 {projects.length === 0 && !shouldShowProjectPathEntry && (
1824 <div className="px-2 pt-4 text-center text-xs text-muted-foreground/60">
1825 No projects yet
1826 </div>
1827 )}
1828 </SidebarGroup>
1829 </SidebarContent>
1830
1831 <SidebarSeparator />
1832 <SidebarFooter className="p-2">
1833 <SidebarMenu>
1834 <SidebarMenuItem>
1835 {isOnSettings ? (
1836 <SidebarMenuButton
1837 size="sm"
1838 className="gap-2 px-2 py-1.5 text-muted-foreground/70 hover:bg-accent hover:text-foreground"
1839 onClick={() => window.history.back()}
1840 >
1841 <ArrowLeftIcon className="size-3.5" />
1842 <span className="text-xs">Back</span>
1843 </SidebarMenuButton>
1844 ) : (
1845 <SidebarMenuButton
1846 size="sm"
1847 className="gap-2 px-2 py-1.5 text-muted-foreground/70 hover:bg-accent hover:text-foreground"
1848 onClick={() => void navigate({ to: "/settings" })}
1849 >
1850 <SettingsIcon className="size-3.5" />
1851 <span className="text-xs">Settings</span>
1852 </SidebarMenuButton>
1853 )}
1854 </SidebarMenuItem>
1855 </SidebarMenu>
1856 </SidebarFooter>
1782 </SidebarFooter>
1783 </>
1784 )}
18571785 </>
18581786 );
18591787}
apps/web/src/components/settings/SettingsPanels.tsx created+1390
......@@ -0,0 +1,1390 @@
1import {
2 ArchiveIcon,
3 ArchiveX,
4 ChevronDownIcon,
5 InfoIcon,
6 LoaderIcon,
7 PlusIcon,
8 RefreshCwIcon,
9 Undo2Icon,
10 XIcon,
11} from "lucide-react";
12import { useQuery, useQueryClient } from "@tanstack/react-query";
13import { type ReactNode, useCallback, useEffect, useMemo, useRef, useState } from "react";
14import {
15 PROVIDER_DISPLAY_NAMES,
16 type ProviderKind,
17 type ServerProvider,
18 type ServerProviderModel,
19 ThreadId,
20} from "@t3tools/contracts";
21import { DEFAULT_UNIFIED_SETTINGS } from "@t3tools/contracts/settings";
22import { normalizeModelSlug } from "@t3tools/shared/model";
23import { Equal } from "effect";
24import { APP_VERSION } from "../../branding";
25import { ProviderModelPicker } from "../chat/ProviderModelPicker";
26import { TraitsPicker } from "../chat/TraitsPicker";
27import { resolveAndPersistPreferredEditor } from "../../editorPreferences";
28import { useTheme } from "../../hooks/useTheme";
29import { useSettings, useUpdateSettings } from "../../hooks/useSettings";
30import { useThreadActions } from "../../hooks/useThreadActions";
31import { serverConfigQueryOptions, serverQueryKeys } from "../../lib/serverReactQuery";
32import {
33 MAX_CUSTOM_MODEL_LENGTH,
34 getCustomModelOptionsByProvider,
35 resolveAppModelSelectionState,
36} from "../../modelSelection";
37import { ensureNativeApi, readNativeApi } from "../../nativeApi";
38import { useStore } from "../../store";
39import { formatRelativeTime, formatRelativeTimeLabel } from "../../timestampFormat";
40import { cn } from "../../lib/utils";
41import { Button } from "../ui/button";
42import { Collapsible, CollapsibleContent } from "../ui/collapsible";
43import { Empty, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle } from "../ui/empty";
44import { Input } from "../ui/input";
45import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "../ui/select";
46import { Switch } from "../ui/switch";
47import { toastManager } from "../ui/toast";
48import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
49import { ProjectFavicon } from "../ProjectFavicon";
50
51const THEME_OPTIONS = [
52 {
53 value: "system",
54 label: "System",
55 },
56 {
57 value: "light",
58 label: "Light",
59 },
60 {
61 value: "dark",
62 label: "Dark",
63 },
64] as const;
65
66const TIMESTAMP_FORMAT_LABELS = {
67 locale: "System default",
68 "12-hour": "12-hour",
69 "24-hour": "24-hour",
70} as const;
71
72const EMPTY_SERVER_PROVIDERS: ReadonlyArray<ServerProvider> = [];
73
74type InstallProviderSettings = {
75 provider: ProviderKind;
76 title: string;
77 binaryPlaceholder: string;
78 binaryDescription: ReactNode;
79 homePathKey?: "codexHomePath";
80 homePlaceholder?: string;
81 homeDescription?: ReactNode;
82};
83
84const PROVIDER_SETTINGS: readonly InstallProviderSettings[] = [
85 {
86 provider: "codex",
87 title: "Codex",
88 binaryPlaceholder: "Codex binary path",
89 binaryDescription: "Path to the Codex binary",
90 homePathKey: "codexHomePath",
91 homePlaceholder: "CODEX_HOME",
92 homeDescription: "Optional custom Codex home and config directory.",
93 },
94 {
95 provider: "claudeAgent",
96 title: "Claude",
97 binaryPlaceholder: "Claude binary path",
98 binaryDescription: "Path to the Claude binary",
99 },
100] as const;
101
102const PROVIDER_STATUS_STYLES = {
103 disabled: {
104 dot: "bg-amber-400",
105 },
106 error: {
107 dot: "bg-destructive",
108 },
109 ready: {
110 dot: "bg-success",
111 },
112 warning: {
113 dot: "bg-warning",
114 },
115} as const;
116
117function getProviderSummary(provider: ServerProvider | undefined) {
118 if (!provider) {
119 return {
120 headline: "Checking provider status",
121 detail: "Waiting for the server to report installation and authentication details.",
122 };
123 }
124 if (!provider.enabled) {
125 return {
126 headline: "Disabled",
127 detail:
128 provider.message ?? "This provider is installed but disabled for new sessions in T3 Code.",
129 };
130 }
131 if (!provider.installed) {
132 return {
133 headline: "Not found",
134 detail: provider.message ?? "CLI not detected on PATH.",
135 };
136 }
137 if (provider.authStatus === "authenticated") {
138 return {
139 headline: "Authenticated",
140 detail: provider.message ?? null,
141 };
142 }
143 if (provider.authStatus === "unauthenticated") {
144 return {
145 headline: "Not authenticated",
146 detail: provider.message ?? null,
147 };
148 }
149 if (provider.status === "warning") {
150 return {
151 headline: "Needs attention",
152 detail:
153 provider.message ?? "The provider is installed, but the server could not fully verify it.",
154 };
155 }
156 if (provider.status === "error") {
157 return {
158 headline: "Unavailable",
159 detail: provider.message ?? "The provider failed its startup checks.",
160 };
161 }
162 return {
163 headline: "Available",
164 detail: provider.message ?? "Installed and ready, but authentication could not be verified.",
165 };
166}
167
168function getProviderVersionLabel(version: string | null | undefined) {
169 if (!version) return null;
170 return version.startsWith("v") ? version : `v${version}`;
171}
172
173function useRelativeTimeTick(intervalMs = 1_000) {
174 const [tick, setTick] = useState(() => Date.now());
175 useEffect(() => {
176 const id = setInterval(() => setTick(Date.now()), intervalMs);
177 return () => clearInterval(id);
178 }, [intervalMs]);
179 return tick;
180}
181
182function ProviderLastChecked({ lastCheckedAt }: { lastCheckedAt: string | null }) {
183 useRelativeTimeTick();
184 const lastCheckedRelative = lastCheckedAt ? formatRelativeTime(lastCheckedAt) : null;
185
186 if (!lastCheckedRelative) {
187 return null;
188 }
189
190 return (
191 <span className="text-[11px] text-muted-foreground/60">
192 {lastCheckedRelative.suffix ? (
193 <>
194 Checked <span className="font-mono tabular-nums">{lastCheckedRelative.value}</span>{" "}
195 {lastCheckedRelative.suffix}
196 </>
197 ) : (
198 <>Checked {lastCheckedRelative.value}</>
199 )}
200 </span>
201 );
202}
203
204function SettingsSection({
205 title,
206 icon,
207 headerAction,
208 children,
209}: {
210 title: string;
211 icon?: ReactNode;
212 headerAction?: ReactNode;
213 children: ReactNode;
214}) {
215 return (
216 <section className="space-y-3">
217 <div className="flex items-center justify-between">
218 <h2 className="flex items-center gap-1.5 text-[11px] font-medium uppercase tracking-[0.14em] text-muted-foreground">
219 {icon}
220 {title}
221 </h2>
222 {headerAction}
223 </div>
224 <div className="relative overflow-hidden rounded-2xl border bg-card text-card-foreground shadow-xs/5 not-dark:bg-clip-padding before:pointer-events-none before:absolute before:inset-0 before:rounded-[calc(var(--radius-2xl)-1px)] before:shadow-[0_1px_--theme(--color-black/4%)] dark:before:shadow-[0_-1px_--theme(--color-white/6%)]">
225 {children}
226 </div>
227 </section>
228 );
229}
230
231function SettingsRow({
232 title,
233 description,
234 status,
235 resetAction,
236 control,
237 children,
238}: {
239 title: string;
240 description: string;
241 status?: ReactNode;
242 resetAction?: ReactNode;
243 control?: ReactNode;
244 children?: ReactNode;
245}) {
246 return (
247 <div className="border-t border-border px-4 py-4 first:border-t-0 sm:px-5">
248 <div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
249 <div className="min-w-0 flex-1 space-y-1">
250 <div className="flex min-h-5 items-center gap-1.5">
251 <h3 className="text-sm font-medium text-foreground">{title}</h3>
252 <span className="inline-flex h-5 w-5 shrink-0 items-center justify-center">
253 {resetAction}
254 </span>
255 </div>
256 <p className="text-xs text-muted-foreground">{description}</p>
257 {status ? <div className="pt-1 text-[11px] text-muted-foreground">{status}</div> : null}
258 </div>
259 {control ? (
260 <div className="flex w-full shrink-0 items-center gap-2 sm:w-auto sm:justify-end">
261 {control}
262 </div>
263 ) : null}
264 </div>
265 {children}
266 </div>
267 );
268}
269
270function SettingResetButton({ label, onClick }: { label: string; onClick: () => void }) {
271 return (
272 <Tooltip>
273 <TooltipTrigger
274 render={
275 <Button
276 size="icon-xs"
277 variant="ghost"
278 aria-label={`Reset ${label} to default`}
279 className="size-5 rounded-sm p-0 text-muted-foreground hover:text-foreground"
280 onClick={(event) => {
281 event.stopPropagation();
282 onClick();
283 }}
284 >
285 <Undo2Icon className="size-3" />
286 </Button>
287 }
288 />
289 <TooltipPopup side="top">Reset to default</TooltipPopup>
290 </Tooltip>
291 );
292}
293
294function SettingsPageContainer({ children }: { children: ReactNode }) {
295 return (
296 <div className="flex-1 overflow-y-auto p-6">
297 <div className="mx-auto flex w-full max-w-4xl flex-col gap-6">{children}</div>
298 </div>
299 );
300}
301
302export function useSettingsRestore(onRestored?: () => void) {
303 const { theme, setTheme } = useTheme();
304 const settings = useSettings();
305 const { resetSettings } = useUpdateSettings();
306
307 const isGitWritingModelDirty = !Equal.equals(
308 settings.textGenerationModelSelection ?? null,
309 DEFAULT_UNIFIED_SETTINGS.textGenerationModelSelection ?? null,
310 );
311 const areProviderSettingsDirty = PROVIDER_SETTINGS.some((providerSettings) => {
312 const currentSettings = settings.providers[providerSettings.provider];
313 const defaultSettings = DEFAULT_UNIFIED_SETTINGS.providers[providerSettings.provider];
314 return !Equal.equals(currentSettings, defaultSettings);
315 });
316
317 const changedSettingLabels = useMemo(
318 () => [
319 ...(theme !== "system" ? ["Theme"] : []),
320 ...(settings.timestampFormat !== DEFAULT_UNIFIED_SETTINGS.timestampFormat
321 ? ["Time format"]
322 : []),
323 ...(settings.diffWordWrap !== DEFAULT_UNIFIED_SETTINGS.diffWordWrap
324 ? ["Diff line wrapping"]
325 : []),
326 ...(settings.enableAssistantStreaming !== DEFAULT_UNIFIED_SETTINGS.enableAssistantStreaming
327 ? ["Assistant output"]
328 : []),
329 ...(settings.defaultThreadEnvMode !== DEFAULT_UNIFIED_SETTINGS.defaultThreadEnvMode
330 ? ["New thread mode"]
331 : []),
332 ...(settings.confirmThreadArchive !== DEFAULT_UNIFIED_SETTINGS.confirmThreadArchive
333 ? ["Archive confirmation"]
334 : []),
335 ...(settings.confirmThreadDelete !== DEFAULT_UNIFIED_SETTINGS.confirmThreadDelete
336 ? ["Delete confirmation"]
337 : []),
338 ...(isGitWritingModelDirty ? ["Git writing model"] : []),
339 ...(areProviderSettingsDirty ? ["Providers"] : []),
340 ],
341 [
342 areProviderSettingsDirty,
343 isGitWritingModelDirty,
344 settings.confirmThreadArchive,
345 settings.confirmThreadDelete,
346 settings.defaultThreadEnvMode,
347 settings.diffWordWrap,
348 settings.enableAssistantStreaming,
349 settings.timestampFormat,
350 theme,
351 ],
352 );
353
354 const restoreDefaults = useCallback(async () => {
355 if (changedSettingLabels.length === 0) return;
356 const api = readNativeApi();
357 const confirmed = await (api ?? ensureNativeApi()).dialogs.confirm(
358 ["Restore default settings?", `This will reset: ${changedSettingLabels.join(", ")}.`].join(
359 "\n",
360 ),
361 );
362 if (!confirmed) return;
363
364 setTheme("system");
365 resetSettings();
366 onRestored?.();
367 }, [changedSettingLabels, onRestored, resetSettings, setTheme]);
368
369 return {
370 changedSettingLabels,
371 restoreDefaults,
372 };
373}
374
375export function GeneralSettingsPanel() {
376 const { theme, setTheme } = useTheme();
377 const settings = useSettings();
378 const { updateSettings } = useUpdateSettings();
379 const serverConfigQuery = useQuery(serverConfigQueryOptions());
380 const [isOpeningKeybindings, setIsOpeningKeybindings] = useState(false);
381 const [openKeybindingsError, setOpenKeybindingsError] = useState<string | null>(null);
382 const [openProviderDetails, setOpenProviderDetails] = useState<Record<ProviderKind, boolean>>({
383 codex: Boolean(
384 settings.providers.codex.binaryPath !== DEFAULT_UNIFIED_SETTINGS.providers.codex.binaryPath ||
385 settings.providers.codex.homePath !== DEFAULT_UNIFIED_SETTINGS.providers.codex.homePath ||
386 settings.providers.codex.customModels.length > 0,
387 ),
388 claudeAgent: Boolean(
389 settings.providers.claudeAgent.binaryPath !==
390 DEFAULT_UNIFIED_SETTINGS.providers.claudeAgent.binaryPath ||
391 settings.providers.claudeAgent.customModels.length > 0,
392 ),
393 });
394 const [customModelInputByProvider, setCustomModelInputByProvider] = useState<
395 Record<ProviderKind, string>
396 >({
397 codex: "",
398 claudeAgent: "",
399 });
400 const [customModelErrorByProvider, setCustomModelErrorByProvider] = useState<
401 Partial<Record<ProviderKind, string | null>>
402 >({});
403 const [isRefreshingProviders, setIsRefreshingProviders] = useState(false);
404 const refreshingRef = useRef(false);
405 const queryClient = useQueryClient();
406 const modelListRefs = useRef<Partial<Record<ProviderKind, HTMLDivElement | null>>>({});
407 const refreshProviders = useCallback(() => {
408 if (refreshingRef.current) return;
409 refreshingRef.current = true;
410 setIsRefreshingProviders(true);
411 void ensureNativeApi()
412 .server.refreshProviders()
413 .then(() => queryClient.invalidateQueries({ queryKey: serverQueryKeys.config() }))
414 .catch((error: unknown) => {
415 console.warn("Failed to refresh providers", error);
416 })
417 .finally(() => {
418 refreshingRef.current = false;
419 setIsRefreshingProviders(false);
420 });
421 }, [queryClient]);
422
423 const keybindingsConfigPath = serverConfigQuery.data?.keybindingsConfigPath ?? null;
424 const availableEditors = serverConfigQuery.data?.availableEditors;
425 const serverProviders = serverConfigQuery.data?.providers ?? EMPTY_SERVER_PROVIDERS;
426 const codexHomePath = settings.providers.codex.homePath;
427
428 const textGenerationModelSelection = resolveAppModelSelectionState(settings, serverProviders);
429 const textGenProvider = textGenerationModelSelection.provider;
430 const textGenModel = textGenerationModelSelection.model;
431 const textGenModelOptions = textGenerationModelSelection.options;
432 const gitModelOptionsByProvider = getCustomModelOptionsByProvider(
433 settings,
434 serverProviders,
435 textGenProvider,
436 textGenModel,
437 );
438 const isGitWritingModelDirty = !Equal.equals(
439 settings.textGenerationModelSelection ?? null,
440 DEFAULT_UNIFIED_SETTINGS.textGenerationModelSelection ?? null,
441 );
442
443 const openKeybindingsFile = useCallback(() => {
444 if (!keybindingsConfigPath) return;
445 setOpenKeybindingsError(null);
446 setIsOpeningKeybindings(true);
447 const editor = resolveAndPersistPreferredEditor(availableEditors ?? []);
448 if (!editor) {
449 setOpenKeybindingsError("No available editors found.");
450 setIsOpeningKeybindings(false);
451 return;
452 }
453 void ensureNativeApi()
454 .shell.openInEditor(keybindingsConfigPath, editor)
455 .catch((error) => {
456 setOpenKeybindingsError(
457 error instanceof Error ? error.message : "Unable to open keybindings file.",
458 );
459 })
460 .finally(() => {
461 setIsOpeningKeybindings(false);
462 });
463 }, [availableEditors, keybindingsConfigPath]);
464
465 const addCustomModel = useCallback(
466 (provider: ProviderKind) => {
467 const customModelInput = customModelInputByProvider[provider];
468 const customModels = settings.providers[provider].customModels;
469 const normalized = normalizeModelSlug(customModelInput, provider);
470 if (!normalized) {
471 setCustomModelErrorByProvider((existing) => ({
472 ...existing,
473 [provider]: "Enter a model slug.",
474 }));
475 return;
476 }
477 if (
478 serverProviders
479 .find((candidate) => candidate.provider === provider)
480 ?.models.some((option) => !option.isCustom && option.slug === normalized)
481 ) {
482 setCustomModelErrorByProvider((existing) => ({
483 ...existing,
484 [provider]: "That model is already built in.",
485 }));
486 return;
487 }
488 if (normalized.length > MAX_CUSTOM_MODEL_LENGTH) {
489 setCustomModelErrorByProvider((existing) => ({
490 ...existing,
491 [provider]: `Model slugs must be ${MAX_CUSTOM_MODEL_LENGTH} characters or less.`,
492 }));
493 return;
494 }
495 if (customModels.includes(normalized)) {
496 setCustomModelErrorByProvider((existing) => ({
497 ...existing,
498 [provider]: "That custom model is already saved.",
499 }));
500 return;
501 }
502
503 updateSettings({
504 providers: {
505 ...settings.providers,
506 [provider]: {
507 ...settings.providers[provider],
508 customModels: [...customModels, normalized],
509 },
510 },
511 });
512 setCustomModelInputByProvider((existing) => ({
513 ...existing,
514 [provider]: "",
515 }));
516 setCustomModelErrorByProvider((existing) => ({
517 ...existing,
518 [provider]: null,
519 }));
520
521 const el = modelListRefs.current[provider];
522 if (!el) return;
523 const scrollToEnd = () => el.scrollTo({ top: el.scrollHeight, behavior: "smooth" });
524 requestAnimationFrame(scrollToEnd);
525 const observer = new MutationObserver(() => {
526 scrollToEnd();
527 observer.disconnect();
528 });
529 observer.observe(el, { childList: true, subtree: true });
530 setTimeout(() => observer.disconnect(), 2_000);
531 },
532 [customModelInputByProvider, serverProviders, settings, updateSettings],
533 );
534
535 const removeCustomModel = useCallback(
536 (provider: ProviderKind, slug: string) => {
537 updateSettings({
538 providers: {
539 ...settings.providers,
540 [provider]: {
541 ...settings.providers[provider],
542 customModels: settings.providers[provider].customModels.filter(
543 (model) => model !== slug,
544 ),
545 },
546 },
547 });
548 setCustomModelErrorByProvider((existing) => ({
549 ...existing,
550 [provider]: null,
551 }));
552 },
553 [settings, updateSettings],
554 );
555
556 const providerCards = PROVIDER_SETTINGS.map((providerSettings) => {
557 const liveProvider = serverProviders.find(
558 (candidate) => candidate.provider === providerSettings.provider,
559 );
560 const providerConfig = settings.providers[providerSettings.provider];
561 const defaultProviderConfig = DEFAULT_UNIFIED_SETTINGS.providers[providerSettings.provider];
562 const statusKey = liveProvider?.status ?? (providerConfig.enabled ? "warning" : "disabled");
563 const summary = getProviderSummary(liveProvider);
564 const models: ReadonlyArray<ServerProviderModel> =
565 liveProvider?.models ??
566 providerConfig.customModels.map((slug) => ({
567 slug,
568 name: slug,
569 isCustom: true,
570 capabilities: null,
571 }));
572
573 return {
574 provider: providerSettings.provider,
575 title: providerSettings.title,
576 binaryPlaceholder: providerSettings.binaryPlaceholder,
577 binaryDescription: providerSettings.binaryDescription,
578 homePathKey: providerSettings.homePathKey,
579 homePlaceholder: providerSettings.homePlaceholder,
580 homeDescription: providerSettings.homeDescription,
581 binaryPathValue: providerConfig.binaryPath,
582 isDirty: !Equal.equals(providerConfig, defaultProviderConfig),
583 liveProvider,
584 models,
585 providerConfig,
586 statusStyle: PROVIDER_STATUS_STYLES[statusKey],
587 summary,
588 versionLabel: getProviderVersionLabel(liveProvider?.version),
589 };
590 });
591
592 const lastCheckedAt =
593 serverProviders.length > 0
594 ? serverProviders.reduce(
595 (latest, provider) => (provider.checkedAt > latest ? provider.checkedAt : latest),
596 serverProviders[0]!.checkedAt,
597 )
598 : null;
599 return (
600 <SettingsPageContainer>
601 <SettingsSection title="General">
602 <SettingsRow
603 title="Theme"
604 description="Choose how T3 Code looks across the app."
605 resetAction={
606 theme !== "system" ? (
607 <SettingResetButton label="theme" onClick={() => setTheme("system")} />
608 ) : null
609 }
610 control={
611 <Select
612 value={theme}
613 onValueChange={(value) => {
614 if (value === "system" || value === "light" || value === "dark") {
615 setTheme(value);
616 }
617 }}
618 >
619 <SelectTrigger className="w-full sm:w-40" aria-label="Theme preference">
620 <SelectValue>
621 {THEME_OPTIONS.find((option) => option.value === theme)?.label ?? "System"}
622 </SelectValue>
623 </SelectTrigger>
624 <SelectPopup align="end" alignItemWithTrigger={false}>
625 {THEME_OPTIONS.map((option) => (
626 <SelectItem hideIndicator key={option.value} value={option.value}>
627 {option.label}
628 </SelectItem>
629 ))}
630 </SelectPopup>
631 </Select>
632 }
633 />
634
635 <SettingsRow
636 title="Time format"
637 description="System default follows your browser or OS clock preference."
638 resetAction={
639 settings.timestampFormat !== DEFAULT_UNIFIED_SETTINGS.timestampFormat ? (
640 <SettingResetButton
641 label="time format"
642 onClick={() =>
643 updateSettings({
644 timestampFormat: DEFAULT_UNIFIED_SETTINGS.timestampFormat,
645 })
646 }
647 />
648 ) : null
649 }
650 control={
651 <Select
652 value={settings.timestampFormat}
653 onValueChange={(value) => {
654 if (value === "locale" || value === "12-hour" || value === "24-hour") {
655 updateSettings({ timestampFormat: value });
656 }
657 }}
658 >
659 <SelectTrigger className="w-full sm:w-40" aria-label="Timestamp format">
660 <SelectValue>{TIMESTAMP_FORMAT_LABELS[settings.timestampFormat]}</SelectValue>
661 </SelectTrigger>
662 <SelectPopup align="end" alignItemWithTrigger={false}>
663 <SelectItem hideIndicator value="locale">
664 {TIMESTAMP_FORMAT_LABELS.locale}
665 </SelectItem>
666 <SelectItem hideIndicator value="12-hour">
667 {TIMESTAMP_FORMAT_LABELS["12-hour"]}
668 </SelectItem>
669 <SelectItem hideIndicator value="24-hour">
670 {TIMESTAMP_FORMAT_LABELS["24-hour"]}
671 </SelectItem>
672 </SelectPopup>
673 </Select>
674 }
675 />
676
677 <SettingsRow
678 title="Diff line wrapping"
679 description="Set the default wrap state when the diff panel opens."
680 resetAction={
681 settings.diffWordWrap !== DEFAULT_UNIFIED_SETTINGS.diffWordWrap ? (
682 <SettingResetButton
683 label="diff line wrapping"
684 onClick={() =>
685 updateSettings({
686 diffWordWrap: DEFAULT_UNIFIED_SETTINGS.diffWordWrap,
687 })
688 }
689 />
690 ) : null
691 }
692 control={
693 <Switch
694 checked={settings.diffWordWrap}
695 onCheckedChange={(checked) => updateSettings({ diffWordWrap: Boolean(checked) })}
696 aria-label="Wrap diff lines by default"
697 />
698 }
699 />
700
701 <SettingsRow
702 title="Assistant output"
703 description="Show token-by-token output while a response is in progress."
704 resetAction={
705 settings.enableAssistantStreaming !==
706 DEFAULT_UNIFIED_SETTINGS.enableAssistantStreaming ? (
707 <SettingResetButton
708 label="assistant output"
709 onClick={() =>
710 updateSettings({
711 enableAssistantStreaming: DEFAULT_UNIFIED_SETTINGS.enableAssistantStreaming,
712 })
713 }
714 />
715 ) : null
716 }
717 control={
718 <Switch
719 checked={settings.enableAssistantStreaming}
720 onCheckedChange={(checked) =>
721 updateSettings({ enableAssistantStreaming: Boolean(checked) })
722 }
723 aria-label="Stream assistant messages"
724 />
725 }
726 />
727
728 <SettingsRow
729 title="New threads"
730 description="Pick the default workspace mode for newly created draft threads."
731 resetAction={
732 settings.defaultThreadEnvMode !== DEFAULT_UNIFIED_SETTINGS.defaultThreadEnvMode ? (
733 <SettingResetButton
734 label="new threads"
735 onClick={() =>
736 updateSettings({
737 defaultThreadEnvMode: DEFAULT_UNIFIED_SETTINGS.defaultThreadEnvMode,
738 })
739 }
740 />
741 ) : null
742 }
743 control={
744 <Select
745 value={settings.defaultThreadEnvMode}
746 onValueChange={(value) => {
747 if (value === "local" || value === "worktree") {
748 updateSettings({ defaultThreadEnvMode: value });
749 }
750 }}
751 >
752 <SelectTrigger className="w-full sm:w-44" aria-label="Default thread mode">
753 <SelectValue>
754 {settings.defaultThreadEnvMode === "worktree" ? "New worktree" : "Local"}
755 </SelectValue>
756 </SelectTrigger>
757 <SelectPopup align="end" alignItemWithTrigger={false}>
758 <SelectItem hideIndicator value="local">
759 Local
760 </SelectItem>
761 <SelectItem hideIndicator value="worktree">
762 New worktree
763 </SelectItem>
764 </SelectPopup>
765 </Select>
766 }
767 />
768
769 <SettingsRow
770 title="Archive confirmation"
771 description="Require a second click on the inline archive action before a thread is archived."
772 resetAction={
773 settings.confirmThreadArchive !== DEFAULT_UNIFIED_SETTINGS.confirmThreadArchive ? (
774 <SettingResetButton
775 label="archive confirmation"
776 onClick={() =>
777 updateSettings({
778 confirmThreadArchive: DEFAULT_UNIFIED_SETTINGS.confirmThreadArchive,
779 })
780 }
781 />
782 ) : null
783 }
784 control={
785 <Switch
786 checked={settings.confirmThreadArchive}
787 onCheckedChange={(checked) =>
788 updateSettings({ confirmThreadArchive: Boolean(checked) })
789 }
790 aria-label="Confirm thread archiving"
791 />
792 }
793 />
794
795 <SettingsRow
796 title="Delete confirmation"
797 description="Ask before deleting a thread and its chat history."
798 resetAction={
799 settings.confirmThreadDelete !== DEFAULT_UNIFIED_SETTINGS.confirmThreadDelete ? (
800 <SettingResetButton
801 label="delete confirmation"
802 onClick={() =>
803 updateSettings({
804 confirmThreadDelete: DEFAULT_UNIFIED_SETTINGS.confirmThreadDelete,
805 })
806 }
807 />
808 ) : null
809 }
810 control={
811 <Switch
812 checked={settings.confirmThreadDelete}
813 onCheckedChange={(checked) =>
814 updateSettings({ confirmThreadDelete: Boolean(checked) })
815 }
816 aria-label="Confirm thread deletion"
817 />
818 }
819 />
820
821 <SettingsRow
822 title="Text generation model"
823 description="Configure the model used for generated commit messages, PR titles, and similar Git text."
824 resetAction={
825 isGitWritingModelDirty ? (
826 <SettingResetButton
827 label="text generation model"
828 onClick={() =>
829 updateSettings({
830 textGenerationModelSelection:
831 DEFAULT_UNIFIED_SETTINGS.textGenerationModelSelection,
832 })
833 }
834 />
835 ) : null
836 }
837 control={
838 <div className="flex flex-wrap items-center justify-end gap-1.5">
839 <ProviderModelPicker
840 provider={textGenProvider}
841 model={textGenModel}
842 lockedProvider={null}
843 providers={serverProviders}
844 modelOptionsByProvider={gitModelOptionsByProvider}
845 triggerVariant="outline"
846 triggerClassName="min-w-0 max-w-none shrink-0 text-foreground/90 hover:text-foreground"
847 onProviderModelChange={(provider, model) => {
848 updateSettings({
849 textGenerationModelSelection: resolveAppModelSelectionState(
850 {
851 ...settings,
852 textGenerationModelSelection: { provider, model },
853 },
854 serverProviders,
855 ),
856 });
857 }}
858 />
859 <TraitsPicker
860 provider={textGenProvider}
861 models={
862 serverProviders.find((provider) => provider.provider === textGenProvider)
863 ?.models ?? []
864 }
865 model={textGenModel}
866 prompt=""
867 onPromptChange={() => {}}
868 modelOptions={textGenModelOptions}
869 allowPromptInjectedEffort={false}
870 triggerVariant="outline"
871 triggerClassName="min-w-0 max-w-none shrink-0 text-foreground/90 hover:text-foreground"
872 onModelOptionsChange={(nextOptions) => {
873 updateSettings({
874 textGenerationModelSelection: resolveAppModelSelectionState(
875 {
876 ...settings,
877 textGenerationModelSelection: {
878 provider: textGenProvider,
879 model: textGenModel,
880 ...(nextOptions ? { options: nextOptions } : {}),
881 },
882 },
883 serverProviders,
884 ),
885 });
886 }}
887 />
888 </div>
889 }
890 />
891 </SettingsSection>
892
893 <SettingsSection
894 title="Providers"
895 headerAction={
896 <div className="flex items-center gap-1.5">
897 <ProviderLastChecked lastCheckedAt={lastCheckedAt} />
898 <Tooltip>
899 <TooltipTrigger
900 render={
901 <Button
902 size="icon-xs"
903 variant="ghost"
904 className="size-5 rounded-sm p-0 text-muted-foreground hover:text-foreground"
905 disabled={isRefreshingProviders}
906 onClick={() => void refreshProviders()}
907 aria-label="Refresh provider status"
908 >
909 {isRefreshingProviders ? (
910 <LoaderIcon className="size-3 animate-spin" />
911 ) : (
912 <RefreshCwIcon className="size-3" />
913 )}
914 </Button>
915 }
916 />
917 <TooltipPopup side="top">Refresh provider status</TooltipPopup>
918 </Tooltip>
919 </div>
920 }
921 >
922 {providerCards.map((providerCard) => {
923 const customModelInput = customModelInputByProvider[providerCard.provider];
924 const customModelError = customModelErrorByProvider[providerCard.provider] ?? null;
925 const providerDisplayName =
926 PROVIDER_DISPLAY_NAMES[providerCard.provider] ?? providerCard.title;
927
928 return (
929 <div key={providerCard.provider} className="border-t border-border first:border-t-0">
930 <div className="px-4 py-4 sm:px-5">
931 <div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
932 <div className="min-w-0 flex-1 space-y-1">
933 <div className="flex min-h-5 items-center gap-1.5">
934 <span
935 className={cn("size-2 shrink-0 rounded-full", providerCard.statusStyle.dot)}
936 />
937 <h3 className="text-sm font-medium text-foreground">{providerDisplayName}</h3>
938 {providerCard.versionLabel ? (
939 <code className="text-xs text-muted-foreground">
940 {providerCard.versionLabel}
941 </code>
942 ) : null}
943 <span className="inline-flex h-5 w-5 shrink-0 items-center justify-center">
944 {providerCard.isDirty ? (
945 <SettingResetButton
946 label={`${providerDisplayName} provider settings`}
947 onClick={() => {
948 updateSettings({
949 providers: {
950 ...settings.providers,
951 [providerCard.provider]:
952 DEFAULT_UNIFIED_SETTINGS.providers[providerCard.provider],
953 },
954 });
955 setCustomModelErrorByProvider((existing) => ({
956 ...existing,
957 [providerCard.provider]: null,
958 }));
959 }}
960 />
961 ) : null}
962 </span>
963 </div>
964 <p className="text-xs text-muted-foreground">
965 {providerCard.summary.headline}
966 {providerCard.summary.detail ? ` - ${providerCard.summary.detail}` : null}
967 </p>
968 </div>
969 <div className="flex w-full shrink-0 items-center gap-2 sm:w-auto sm:justify-end">
970 <Button
971 size="sm"
972 variant="ghost"
973 className="h-7 px-2 text-xs text-muted-foreground hover:text-foreground"
974 onClick={() =>
975 setOpenProviderDetails((existing) => ({
976 ...existing,
977 [providerCard.provider]: !existing[providerCard.provider],
978 }))
979 }
980 aria-label={`Toggle ${providerDisplayName} details`}
981 >
982 <ChevronDownIcon
983 className={cn(
984 "size-3.5 transition-transform",
985 openProviderDetails[providerCard.provider] && "rotate-180",
986 )}
987 />
988 </Button>
989 <Switch
990 checked={providerCard.providerConfig.enabled}
991 onCheckedChange={(checked) => {
992 const isDisabling = !checked;
993 const shouldClearModelSelection =
994 isDisabling && textGenProvider === providerCard.provider;
995 updateSettings({
996 providers: {
997 ...settings.providers,
998 [providerCard.provider]: {
999 ...settings.providers[providerCard.provider],
1000 enabled: Boolean(checked),
1001 },
1002 },
1003 ...(shouldClearModelSelection
1004 ? {
1005 textGenerationModelSelection:
1006 DEFAULT_UNIFIED_SETTINGS.textGenerationModelSelection,
1007 }
1008 : {}),
1009 });
1010 }}
1011 aria-label={`Enable ${providerDisplayName}`}
1012 />
1013 </div>
1014 </div>
1015 </div>
1016
1017 <Collapsible
1018 open={openProviderDetails[providerCard.provider]}
1019 onOpenChange={(open) =>
1020 setOpenProviderDetails((existing) => ({
1021 ...existing,
1022 [providerCard.provider]: open,
1023 }))
1024 }
1025 >
1026 <CollapsibleContent>
1027 <div className="space-y-0">
1028 <div className="border-t border-border/60 px-4 py-3 sm:px-5">
1029 <label
1030 htmlFor={`provider-install-${providerCard.provider}-binary-path`}
1031 className="block"
1032 >
1033 <span className="text-xs font-medium text-foreground">
1034 {providerDisplayName} binary path
1035 </span>
1036 <Input
1037 id={`provider-install-${providerCard.provider}-binary-path`}
1038 className="mt-1.5"
1039 value={providerCard.binaryPathValue}
1040 onChange={(event) =>
1041 updateSettings({
1042 providers: {
1043 ...settings.providers,
1044 [providerCard.provider]: {
1045 ...settings.providers[providerCard.provider],
1046 binaryPath: event.target.value,
1047 },
1048 },
1049 })
1050 }
1051 placeholder={providerCard.binaryPlaceholder}
1052 spellCheck={false}
1053 />
1054 <span className="mt-1 block text-xs text-muted-foreground">
1055 {providerCard.binaryDescription}
1056 </span>
1057 </label>
1058 </div>
1059
1060 {providerCard.homePathKey ? (
1061 <div className="border-t border-border/60 px-4 py-3 sm:px-5">
1062 <label
1063 htmlFor={`provider-install-${providerCard.homePathKey}`}
1064 className="block"
1065 >
1066 <span className="text-xs font-medium text-foreground">
1067 CODEX_HOME path
1068 </span>
1069 <Input
1070 id={`provider-install-${providerCard.homePathKey}`}
1071 className="mt-1.5"
1072 value={codexHomePath}
1073 onChange={(event) =>
1074 updateSettings({
1075 providers: {
1076 ...settings.providers,
1077 codex: {
1078 ...settings.providers.codex,
1079 homePath: event.target.value,
1080 },
1081 },
1082 })
1083 }
1084 placeholder={providerCard.homePlaceholder}
1085 spellCheck={false}
1086 />
1087 {providerCard.homeDescription ? (
1088 <span className="mt-1 block text-xs text-muted-foreground">
1089 {providerCard.homeDescription}
1090 </span>
1091 ) : null}
1092 </label>
1093 </div>
1094 ) : null}
1095
1096 <div className="border-t border-border/60 px-4 py-3 sm:px-5">
1097 <div className="text-xs font-medium text-foreground">Models</div>
1098 <div className="mt-1 text-xs text-muted-foreground">
1099 {providerCard.models.length} model
1100 {providerCard.models.length === 1 ? "" : "s"} available.
1101 </div>
1102 <div
1103 ref={(el) => {
1104 modelListRefs.current[providerCard.provider] = el;
1105 }}
1106 className="mt-2 max-h-40 overflow-y-auto pb-1"
1107 >
1108 {providerCard.models.map((model) => {
1109 const caps = model.capabilities;
1110 const capLabels: string[] = [];
1111 if (caps?.supportsFastMode) capLabels.push("Fast mode");
1112 if (caps?.supportsThinkingToggle) capLabels.push("Thinking");
1113 if (
1114 caps?.reasoningEffortLevels &&
1115 caps.reasoningEffortLevels.length > 0
1116 ) {
1117 capLabels.push("Reasoning");
1118 }
1119 const hasDetails = capLabels.length > 0 || model.name !== model.slug;
1120
1121 return (
1122 <div
1123 key={`${providerCard.provider}:${model.slug}`}
1124 className="flex items-center gap-2 py-1"
1125 >
1126 <span className="min-w-0 truncate text-xs text-foreground/90">
1127 {model.name}
1128 </span>
1129 {hasDetails ? (
1130 <Tooltip>
1131 <TooltipTrigger
1132 render={
1133 <button
1134 type="button"
1135 className="shrink-0 text-muted-foreground/40 transition-colors hover:text-muted-foreground"
1136 aria-label={`Details for ${model.name}`}
1137 />
1138 }
1139 >
1140 <InfoIcon className="size-3" />
1141 </TooltipTrigger>
1142 <TooltipPopup side="top" className="max-w-56">
1143 <div className="space-y-1">
1144 <code className="block text-[11px] text-foreground">
1145 {model.slug}
1146 </code>
1147 {capLabels.length > 0 ? (
1148 <div className="flex flex-wrap gap-x-2 gap-y-0.5">
1149 {capLabels.map((label) => (
1150 <span
1151 key={label}
1152 className="text-[10px] text-muted-foreground"
1153 >
1154 {label}
1155 </span>
1156 ))}
1157 </div>
1158 ) : null}
1159 </div>
1160 </TooltipPopup>
1161 </Tooltip>
1162 ) : null}
1163 {model.isCustom ? (
1164 <div className="ml-auto flex shrink-0 items-center gap-1.5">
1165 <span className="text-[10px] text-muted-foreground">custom</span>
1166 <button
1167 type="button"
1168 className="text-muted-foreground transition-colors hover:text-foreground"
1169 aria-label={`Remove ${model.slug}`}
1170 onClick={() =>
1171 removeCustomModel(providerCard.provider, model.slug)
1172 }
1173 >
1174 <XIcon className="size-3" />
1175 </button>
1176 </div>
1177 ) : null}
1178 </div>
1179 );
1180 })}
1181 </div>
1182
1183 <div className="mt-3 flex flex-col gap-2 sm:flex-row">
1184 <Input
1185 id={`custom-model-${providerCard.provider}`}
1186 value={customModelInput}
1187 onChange={(event) => {
1188 const value = event.target.value;
1189 setCustomModelInputByProvider((existing) => ({
1190 ...existing,
1191 [providerCard.provider]: value,
1192 }));
1193 if (customModelError) {
1194 setCustomModelErrorByProvider((existing) => ({
1195 ...existing,
1196 [providerCard.provider]: null,
1197 }));
1198 }
1199 }}
1200 onKeyDown={(event) => {
1201 if (event.key !== "Enter") return;
1202 event.preventDefault();
1203 addCustomModel(providerCard.provider);
1204 }}
1205 placeholder={
1206 providerCard.provider === "codex"
1207 ? "gpt-6.7-codex-ultra-preview"
1208 : "claude-sonnet-5-0"
1209 }
1210 spellCheck={false}
1211 />
1212 <Button
1213 className="shrink-0"
1214 variant="outline"
1215 onClick={() => addCustomModel(providerCard.provider)}
1216 >
1217 <PlusIcon className="size-3.5" />
1218 Add
1219 </Button>
1220 </div>
1221
1222 {customModelError ? (
1223 <p className="mt-2 text-xs text-destructive">{customModelError}</p>
1224 ) : null}
1225 </div>
1226 </div>
1227 </CollapsibleContent>
1228 </Collapsible>
1229 </div>
1230 );
1231 })}
1232 </SettingsSection>
1233
1234 <SettingsSection title="Advanced">
1235 <SettingsRow
1236 title="Keybindings"
1237 description="Open the persisted `keybindings.json` file to edit advanced bindings directly."
1238 status={
1239 <>
1240 <span className="block break-all font-mono text-[11px] text-foreground">
1241 {keybindingsConfigPath ?? "Resolving keybindings path..."}
1242 </span>
1243 {openKeybindingsError ? (
1244 <span className="mt-1 block text-destructive">{openKeybindingsError}</span>
1245 ) : (
1246 <span className="mt-1 block">Opens in your preferred editor.</span>
1247 )}
1248 </>
1249 }
1250 control={
1251 <Button
1252 size="xs"
1253 variant="outline"
1254 disabled={!keybindingsConfigPath || isOpeningKeybindings}
1255 onClick={openKeybindingsFile}
1256 >
1257 {isOpeningKeybindings ? "Opening..." : "Open file"}
1258 </Button>
1259 }
1260 />
1261
1262 <SettingsRow
1263 title="Version"
1264 description="Current application version."
1265 control={<code className="text-xs font-medium text-muted-foreground">{APP_VERSION}</code>}
1266 />
1267 </SettingsSection>
1268 </SettingsPageContainer>
1269 );
1270}
1271
1272export function ArchivedThreadsPanel() {
1273 const projects = useStore((store) => store.projects);
1274 const threads = useStore((store) => store.threads);
1275 const { unarchiveThread, confirmAndDeleteThread } = useThreadActions();
1276 const archivedGroups = useMemo(() => {
1277 const projectById = new Map(projects.map((project) => [project.id, project] as const));
1278 return [...projectById.values()]
1279 .map((project) => ({
1280 project,
1281 threads: threads
1282 .filter((thread) => thread.projectId === project.id && thread.archivedAt !== null)
1283 .toSorted((left, right) => {
1284 const leftKey = left.archivedAt ?? left.createdAt;
1285 const rightKey = right.archivedAt ?? right.createdAt;
1286 return rightKey.localeCompare(leftKey) || right.id.localeCompare(left.id);
1287 }),
1288 }))
1289 .filter((group) => group.threads.length > 0);
1290 }, [projects, threads]);
1291
1292 const handleArchivedThreadContextMenu = useCallback(
1293 async (threadId: ThreadId, position: { x: number; y: number }) => {
1294 const api = readNativeApi();
1295 if (!api) return;
1296 const clicked = await api.contextMenu.show(
1297 [
1298 { id: "unarchive", label: "Unarchive" },
1299 { id: "delete", label: "Delete", destructive: true },
1300 ],
1301 position,
1302 );
1303
1304 if (clicked === "unarchive") {
1305 try {
1306 await unarchiveThread(threadId);
1307 } catch (error) {
1308 toastManager.add({
1309 type: "error",
1310 title: "Failed to unarchive thread",
1311 description: error instanceof Error ? error.message : "An error occurred.",
1312 });
1313 }
1314 return;
1315 }
1316
1317 if (clicked === "delete") {
1318 await confirmAndDeleteThread(threadId);
1319 }
1320 },
1321 [confirmAndDeleteThread, unarchiveThread],
1322 );
1323
1324 return (
1325 <SettingsPageContainer>
1326 {archivedGroups.length === 0 ? (
1327 <SettingsSection title="Archived threads">
1328 <Empty className="min-h-[22rem]">
1329 <EmptyMedia variant="icon">
1330 <ArchiveIcon />
1331 </EmptyMedia>
1332 <EmptyHeader>
1333 <EmptyTitle>No archived threads</EmptyTitle>
1334 <EmptyDescription>Archived threads will appear here.</EmptyDescription>
1335 </EmptyHeader>
1336 </Empty>
1337 </SettingsSection>
1338 ) : (
1339 archivedGroups.map(({ project, threads: projectThreads }) => (
1340 <SettingsSection
1341 key={project.id}
1342 title={project.name}
1343 icon={<ProjectFavicon cwd={project.cwd} />}
1344 >
1345 {projectThreads.map((thread) => (
1346 <div
1347 key={thread.id}
1348 className="flex items-center justify-between gap-3 border-t border-border px-4 py-3 first:border-t-0 sm:px-5"
1349 onContextMenu={(event) => {
1350 event.preventDefault();
1351 void handleArchivedThreadContextMenu(thread.id, {
1352 x: event.clientX,
1353 y: event.clientY,
1354 });
1355 }}
1356 >
1357 <div className="min-w-0 flex-1">
1358 <h3 className="truncate text-sm font-medium text-foreground">{thread.title}</h3>
1359 <p className="text-xs text-muted-foreground">
1360 Archived {formatRelativeTimeLabel(thread.archivedAt ?? thread.createdAt)}
1361 {" \u00b7 Created "}
1362 {formatRelativeTimeLabel(thread.createdAt)}
1363 </p>
1364 </div>
1365 <Button
1366 type="button"
1367 variant="outline"
1368 size="sm"
1369 className="h-7 shrink-0 cursor-pointer gap-1.5 px-2.5"
1370 onClick={() =>
1371 void unarchiveThread(thread.id).catch((error) => {
1372 toastManager.add({
1373 type: "error",
1374 title: "Failed to unarchive thread",
1375 description: error instanceof Error ? error.message : "An error occurred.",
1376 });
1377 })
1378 }
1379 >
1380 <ArchiveX className="size-3.5" />
1381 <span>Unarchive</span>
1382 </Button>
1383 </div>
1384 ))}
1385 </SettingsSection>
1386 ))
1387 )}
1388 </SettingsPageContainer>
1389 );
1390}
apps/web/src/components/settings/SettingsSidebarNav.tsx created+82
......@@ -0,0 +1,82 @@
1import type { ComponentType } from "react";
2import { ArchiveIcon, ArrowLeftIcon, Settings2Icon } from "lucide-react";
3import { useNavigate } from "@tanstack/react-router";
4
5import {
6 SidebarContent,
7 SidebarFooter,
8 SidebarGroup,
9 SidebarMenu,
10 SidebarMenuButton,
11 SidebarMenuItem,
12 SidebarSeparator,
13} from "../ui/sidebar";
14
15export type SettingsSectionPath = "/settings/general" | "/settings/archived";
16
17export const SETTINGS_NAV_ITEMS: ReadonlyArray<{
18 label: string;
19 to: SettingsSectionPath;
20 icon: ComponentType<{ className?: string }>;
21}> = [
22 { label: "General", to: "/settings/general", icon: Settings2Icon },
23 { label: "Archived threads", to: "/settings/archived", icon: ArchiveIcon },
24];
25
26export function SettingsSidebarNav({ pathname }: { pathname: string }) {
27 const navigate = useNavigate();
28
29 return (
30 <>
31 <SidebarContent className="overflow-x-hidden">
32 <SidebarGroup className="px-2 py-3">
33 <SidebarMenu>
34 {SETTINGS_NAV_ITEMS.map((item) => {
35 const Icon = item.icon;
36 const isActive = pathname === item.to;
37 return (
38 <SidebarMenuItem key={item.to}>
39 <SidebarMenuButton
40 size="sm"
41 isActive={isActive}
42 className={
43 isActive
44 ? "gap-2 px-2 py-2 text-left text-sm text-foreground"
45 : "gap-2 px-2 py-2 text-left text-sm text-muted-foreground hover:text-foreground/80"
46 }
47 onClick={() => void navigate({ to: item.to, replace: true })}
48 >
49 <Icon
50 className={
51 isActive
52 ? "size-4 shrink-0 text-foreground"
53 : "size-4 shrink-0 text-muted-foreground"
54 }
55 />
56 <span className="truncate">{item.label}</span>
57 </SidebarMenuButton>
58 </SidebarMenuItem>
59 );
60 })}
61 </SidebarMenu>
62 </SidebarGroup>
63 </SidebarContent>
64
65 <SidebarSeparator />
66 <SidebarFooter className="p-2">
67 <SidebarMenu>
68 <SidebarMenuItem>
69 <SidebarMenuButton
70 size="sm"
71 className="gap-2 px-2 py-2 text-sm text-muted-foreground hover:bg-accent hover:text-foreground"
72 onClick={() => window.history.back()}
73 >
74 <ArrowLeftIcon className="size-4" />
75 <span>Back</span>
76 </SidebarMenuButton>
77 </SidebarMenuItem>
78 </SidebarMenu>
79 </SidebarFooter>
80 </>
81 );
82}
apps/web/src/contextMenuFallback.ts+10-4
......@@ -44,10 +44,16 @@ export function showContextMenuFallback<T extends string>(
4444 btn.type = "button";
4545 btn.textContent = item.label;
4646 const isDestructiveAction = item.destructive === true || item.id === "delete";
47 btn.className = isDestructiveAction
48 ? "flex w-full items-center gap-2 px-3 py-1.5 text-left text-[11px] text-destructive hover:bg-accent cursor-default"
49 : "flex w-full items-center gap-2 px-3 py-1.5 text-left text-[11px] text-popover-foreground hover:bg-accent cursor-default";
50 btn.addEventListener("click", () => cleanup(item.id));
47 const isDisabled = item.disabled === true;
48 btn.disabled = isDisabled;
49 btn.className = isDisabled
50 ? "flex w-full items-center gap-2 px-3 py-1.5 text-left text-[11px] text-muted-foreground/60 cursor-not-allowed"
51 : isDestructiveAction
52 ? "flex w-full items-center gap-2 px-3 py-1.5 text-left text-[11px] text-destructive hover:bg-accent cursor-default"
53 : "flex w-full items-center gap-2 px-3 py-1.5 text-left text-[11px] text-popover-foreground hover:bg-accent cursor-default";
54 if (!isDisabled) {
55 btn.addEventListener("click", () => cleanup(item.id));
56 }
5157 menu.appendChild(btn);
5258 }
5359
apps/web/src/hooks/useSettings.test.ts created+16
......@@ -0,0 +1,16 @@
1import { describe, expect, it } from "vitest";
2import { buildLegacyClientSettingsMigrationPatch } from "./useSettings";
3
4describe("buildLegacyClientSettingsMigrationPatch", () => {
5 it("migrates archive confirmation from legacy local settings", () => {
6 expect(
7 buildLegacyClientSettingsMigrationPatch({
8 confirmThreadArchive: true,
9 confirmThreadDelete: false,
10 }),
11 ).toEqual({
12 confirmThreadArchive: true,
13 confirmThreadDelete: false,
14 });
15 });
16});
apps/web/src/hooks/useSettings.ts+4
......@@ -202,6 +202,10 @@ export function buildLegacyClientSettingsMigrationPatch(
202202): Partial<DeepMutable<ClientSettings>> {
203203 const patch: Partial<DeepMutable<ClientSettings>> = {};
204204
205 if (Predicate.isBoolean(legacySettings.confirmThreadArchive)) {
206 patch.confirmThreadArchive = legacySettings.confirmThreadArchive;
207 }
208
205209 if (Predicate.isBoolean(legacySettings.confirmThreadDelete)) {
206210 patch.confirmThreadDelete = legacySettings.confirmThreadDelete;
207211 }
apps/web/src/hooks/useThreadActions.ts created+211
......@@ -0,0 +1,211 @@
1import { ThreadId } from "@t3tools/contracts";
2import { useMutation, useQueryClient } from "@tanstack/react-query";
3import { useNavigate, useParams } from "@tanstack/react-router";
4import { useCallback } from "react";
5
6import { getFallbackThreadIdAfterDelete } from "../components/Sidebar.logic";
7import { useComposerDraftStore } from "../composerDraftStore";
8import { useHandleNewThread } from "./useHandleNewThread";
9import { gitRemoveWorktreeMutationOptions } from "../lib/gitReactQuery";
10import { newCommandId } from "../lib/utils";
11import { readNativeApi } from "../nativeApi";
12import { useStore } from "../store";
13import { useTerminalStateStore } from "../terminalStateStore";
14import { formatWorktreePathForDisplay, getOrphanedWorktreePathForThread } from "../worktreeCleanup";
15import { toastManager } from "../components/ui/toast";
16import { useSettings } from "./useSettings";
17
18export function useThreadActions() {
19 const threads = useStore((store) => store.threads);
20 const projects = useStore((store) => store.projects);
21 const appSettings = useSettings();
22 const clearComposerDraftForThread = useComposerDraftStore((store) => store.clearDraftThread);
23 const clearProjectDraftThreadById = useComposerDraftStore(
24 (store) => store.clearProjectDraftThreadById,
25 );
26 const clearTerminalState = useTerminalStateStore((state) => state.clearTerminalState);
27 const routeThreadId = useParams({
28 strict: false,
29 select: (params) => (params.threadId ? ThreadId.makeUnsafe(params.threadId) : null),
30 });
31 const navigate = useNavigate();
32 const { handleNewThread } = useHandleNewThread();
33 const queryClient = useQueryClient();
34 const removeWorktreeMutation = useMutation(gitRemoveWorktreeMutationOptions({ queryClient }));
35
36 const archiveThread = useCallback(
37 async (threadId: ThreadId) => {
38 const api = readNativeApi();
39 if (!api) return;
40 const thread = threads.find((entry) => entry.id === threadId);
41 if (!thread) return;
42 if (thread.session?.status === "running" && thread.session.activeTurnId != null) {
43 throw new Error("Cannot archive a running thread.");
44 }
45
46 await api.orchestration.dispatchCommand({
47 type: "thread.archive",
48 commandId: newCommandId(),
49 threadId,
50 });
51
52 if (routeThreadId === threadId) {
53 await handleNewThread(thread.projectId);
54 }
55 },
56 [handleNewThread, routeThreadId, threads],
57 );
58
59 const unarchiveThread = useCallback(async (threadId: ThreadId) => {
60 const api = readNativeApi();
61 if (!api) return;
62 await api.orchestration.dispatchCommand({
63 type: "thread.unarchive",
64 commandId: newCommandId(),
65 threadId,
66 });
67 }, []);
68
69 const deleteThread = useCallback(
70 async (threadId: ThreadId, opts: { deletedThreadIds?: ReadonlySet<ThreadId> } = {}) => {
71 const api = readNativeApi();
72 if (!api) return;
73 const thread = threads.find((entry) => entry.id === threadId);
74 if (!thread) return;
75 const threadProject = projects.find((project) => project.id === thread.projectId);
76 const deletedIds = opts.deletedThreadIds;
77 const survivingThreads =
78 deletedIds && deletedIds.size > 0
79 ? threads.filter((entry) => entry.id === threadId || !deletedIds.has(entry.id))
80 : threads;
81 const orphanedWorktreePath = getOrphanedWorktreePathForThread(survivingThreads, threadId);
82 const displayWorktreePath = orphanedWorktreePath
83 ? formatWorktreePathForDisplay(orphanedWorktreePath)
84 : null;
85 const canDeleteWorktree = orphanedWorktreePath !== null && threadProject !== undefined;
86 const shouldDeleteWorktree =
87 canDeleteWorktree &&
88 (await api.dialogs.confirm(
89 [
90 "This thread is the only one linked to this worktree:",
91 displayWorktreePath ?? orphanedWorktreePath,
92 "",
93 "Delete the worktree too?",
94 ].join("\n"),
95 ));
96
97 if (thread.session && thread.session.status !== "closed") {
98 await api.orchestration
99 .dispatchCommand({
100 type: "thread.session.stop",
101 commandId: newCommandId(),
102 threadId,
103 createdAt: new Date().toISOString(),
104 })
105 .catch(() => undefined);
106 }
107
108 try {
109 await api.terminal.close({ threadId, deleteHistory: true });
110 } catch {
111 // Terminal may already be closed.
112 }
113
114 const deletedThreadIds = opts.deletedThreadIds ?? new Set<ThreadId>();
115 const shouldNavigateToFallback = routeThreadId === threadId;
116 const fallbackThreadId = getFallbackThreadIdAfterDelete({
117 threads,
118 deletedThreadId: threadId,
119 deletedThreadIds,
120 sortOrder: appSettings.sidebarThreadSortOrder,
121 });
122 await api.orchestration.dispatchCommand({
123 type: "thread.delete",
124 commandId: newCommandId(),
125 threadId,
126 });
127 clearComposerDraftForThread(threadId);
128 clearProjectDraftThreadById(thread.projectId, thread.id);
129 clearTerminalState(threadId);
130
131 if (shouldNavigateToFallback) {
132 if (fallbackThreadId) {
133 await navigate({
134 to: "/$threadId",
135 params: { threadId: fallbackThreadId },
136 replace: true,
137 });
138 } else {
139 await navigate({ to: "/", replace: true });
140 }
141 }
142
143 if (!shouldDeleteWorktree || !orphanedWorktreePath || !threadProject) {
144 return;
145 }
146
147 try {
148 await removeWorktreeMutation.mutateAsync({
149 cwd: threadProject.cwd,
150 path: orphanedWorktreePath,
151 force: true,
152 });
153 } catch (error) {
154 const message = error instanceof Error ? error.message : "Unknown error removing worktree.";
155 console.error("Failed to remove orphaned worktree after thread deletion", {
156 threadId,
157 projectCwd: threadProject.cwd,
158 worktreePath: orphanedWorktreePath,
159 error,
160 });
161 toastManager.add({
162 type: "error",
163 title: "Thread deleted, but worktree removal failed",
164 description: `Could not remove ${displayWorktreePath ?? orphanedWorktreePath}. ${message}`,
165 });
166 }
167 },
168 [
169 clearComposerDraftForThread,
170 clearProjectDraftThreadById,
171 clearTerminalState,
172 appSettings.sidebarThreadSortOrder,
173 navigate,
174 projects,
175 removeWorktreeMutation,
176 routeThreadId,
177 threads,
178 ],
179 );
180
181 const confirmAndDeleteThread = useCallback(
182 async (threadId: ThreadId) => {
183 const api = readNativeApi();
184 if (!api) return;
185 const thread = threads.find((entry) => entry.id === threadId);
186 if (!thread) return;
187
188 if (appSettings.confirmThreadDelete) {
189 const confirmed = await api.dialogs.confirm(
190 [
191 `Delete thread "${thread.title}"?`,
192 "This permanently clears conversation history for this thread.",
193 ].join("\n"),
194 );
195 if (!confirmed) {
196 return;
197 }
198 }
199
200 await deleteThread(threadId);
201 },
202 [appSettings.confirmThreadDelete, deleteThread, threads],
203 );
204
205 return {
206 archiveThread,
207 unarchiveThread,
208 deleteThread,
209 confirmAndDeleteThread,
210 };
211}
apps/web/src/lib/storage.ts+21-4
......@@ -23,25 +23,42 @@ export function createMemoryStorage(): StateStorage {
2323 };
2424}
2525
26export function isStateStorage(
27 storage: Partial<StateStorage> | null | undefined,
28): storage is StateStorage {
29 return (
30 storage !== null &&
31 storage !== undefined &&
32 typeof storage.getItem === "function" &&
33 typeof storage.setItem === "function" &&
34 typeof storage.removeItem === "function"
35 );
36}
37
38export function resolveStorage(storage: Partial<StateStorage> | null | undefined): StateStorage {
39 return isStateStorage(storage) ? storage : createMemoryStorage();
40}
41
2642export function createDebouncedStorage(
27 baseStorage: StateStorage,
43 baseStorage: Partial<StateStorage> | null | undefined,
2844 debounceMs: number = 300,
2945): DebouncedStorage {
46 const resolvedStorage = resolveStorage(baseStorage);
3047 const debouncedSetItem = new Debouncer(
3148 (name: string, value: string) => {
32 baseStorage.setItem(name, value);
49 resolvedStorage.setItem(name, value);
3350 },
3451 { wait: debounceMs },
3552 );
3653
3754 return {
38 getItem: (name) => baseStorage.getItem(name),
55 getItem: (name) => resolvedStorage.getItem(name),
3956 setItem: (name, value) => {
4057 debouncedSetItem.maybeExecute(name, value);
4158 },
4259 removeItem: (name) => {
4360 debouncedSetItem.cancel();
44 baseStorage.removeItem(name);
61 resolvedStorage.removeItem(name);
4562 },
4663 flush: () => {
4764 debouncedSetItem.flush();
apps/web/src/routeTree.gen.ts+82-19
......@@ -9,11 +9,18 @@
99// Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified.
1010
1111import { Route as rootRouteImport } from './routes/__root'
12import { Route as SettingsRouteImport } from './routes/settings'
1213import { Route as ChatRouteImport } from './routes/_chat'
1314import { Route as ChatIndexRouteImport } from './routes/_chat.index'
14import { Route as ChatSettingsRouteImport } from './routes/_chat.settings'
15import { Route as SettingsGeneralRouteImport } from './routes/settings.general'
16import { Route as SettingsArchivedRouteImport } from './routes/settings.archived'
1517import { Route as ChatThreadIdRouteImport } from './routes/_chat.$threadId'
1618
19const SettingsRoute = SettingsRouteImport.update({
20 id: '/settings',
21 path: '/settings',
22 getParentRoute: () => rootRouteImport,
23} as any)
1724const ChatRoute = ChatRouteImport.update({
1825 id: '/_chat',
1926 getParentRoute: () => rootRouteImport,
......@@ -23,10 +30,15 @@ const ChatIndexRoute = ChatIndexRouteImport.update({
2330 path: '/',
2431 getParentRoute: () => ChatRoute,
2532} as any)
26const ChatSettingsRoute = ChatSettingsRouteImport.update({
27 id: '/settings',
28 path: '/settings',
29 getParentRoute: () => ChatRoute,
33const SettingsGeneralRoute = SettingsGeneralRouteImport.update({
34 id: '/general',
35 path: '/general',
36 getParentRoute: () => SettingsRoute,
37} as any)
38const SettingsArchivedRoute = SettingsArchivedRouteImport.update({
39 id: '/archived',
40 path: '/archived',
41 getParentRoute: () => SettingsRoute,
3042} as any)
3143const ChatThreadIdRoute = ChatThreadIdRouteImport.update({
3244 id: '/$threadId',
......@@ -36,35 +48,66 @@ const ChatThreadIdRoute = ChatThreadIdRouteImport.update({
3648
3749export interface FileRoutesByFullPath {
3850 '/': typeof ChatIndexRoute
51 '/settings': typeof SettingsRouteWithChildren
3952 '/$threadId': typeof ChatThreadIdRoute
40 '/settings': typeof ChatSettingsRoute
53 '/settings/archived': typeof SettingsArchivedRoute
54 '/settings/general': typeof SettingsGeneralRoute
4155}
4256export interface FileRoutesByTo {
57 '/settings': typeof SettingsRouteWithChildren
4358 '/$threadId': typeof ChatThreadIdRoute
44 '/settings': typeof ChatSettingsRoute
59 '/settings/archived': typeof SettingsArchivedRoute
60 '/settings/general': typeof SettingsGeneralRoute
4561 '/': typeof ChatIndexRoute
4662}
4763export interface FileRoutesById {
4864 __root__: typeof rootRouteImport
4965 '/_chat': typeof ChatRouteWithChildren
66 '/settings': typeof SettingsRouteWithChildren
5067 '/_chat/$threadId': typeof ChatThreadIdRoute
51 '/_chat/settings': typeof ChatSettingsRoute
68 '/settings/archived': typeof SettingsArchivedRoute
69 '/settings/general': typeof SettingsGeneralRoute
5270 '/_chat/': typeof ChatIndexRoute
5371}
5472export interface FileRouteTypes {
5573 fileRoutesByFullPath: FileRoutesByFullPath
56 fullPaths: '/' | '/$threadId' | '/settings'
74 fullPaths:
75 | '/'
76 | '/settings'
77 | '/$threadId'
78 | '/settings/archived'
79 | '/settings/general'
5780 fileRoutesByTo: FileRoutesByTo
58 to: '/$threadId' | '/settings' | '/'
59 id: '__root__' | '/_chat' | '/_chat/$threadId' | '/_chat/settings' | '/_chat/'
81 to:
82 | '/settings'
83 | '/$threadId'
84 | '/settings/archived'
85 | '/settings/general'
86 | '/'
87 id:
88 | '__root__'
89 | '/_chat'
90 | '/settings'
91 | '/_chat/$threadId'
92 | '/settings/archived'
93 | '/settings/general'
94 | '/_chat/'
6095 fileRoutesById: FileRoutesById
6196}
6297export interface RootRouteChildren {
6398 ChatRoute: typeof ChatRouteWithChildren
99 SettingsRoute: typeof SettingsRouteWithChildren
64100}
65101
66102declare module '@tanstack/react-router' {
67103 interface FileRoutesByPath {
104 '/settings': {
105 id: '/settings'
106 path: '/settings'
107 fullPath: '/settings'
108 preLoaderRoute: typeof SettingsRouteImport
109 parentRoute: typeof rootRouteImport
110 }
68111 '/_chat': {
69112 id: '/_chat'
70113 path: ''
......@@ -79,12 +122,19 @@ declare module '@tanstack/react-router' {
79122 preLoaderRoute: typeof ChatIndexRouteImport
80123 parentRoute: typeof ChatRoute
81124 }
82 '/_chat/settings': {
83 id: '/_chat/settings'
84 path: '/settings'
85 fullPath: '/settings'
86 preLoaderRoute: typeof ChatSettingsRouteImport
87 parentRoute: typeof ChatRoute
125 '/settings/general': {
126 id: '/settings/general'
127 path: '/general'
128 fullPath: '/settings/general'
129 preLoaderRoute: typeof SettingsGeneralRouteImport
130 parentRoute: typeof SettingsRoute
131 }
132 '/settings/archived': {
133 id: '/settings/archived'
134 path: '/archived'
135 fullPath: '/settings/archived'
136 preLoaderRoute: typeof SettingsArchivedRouteImport
137 parentRoute: typeof SettingsRoute
88138 }
89139 '/_chat/$threadId': {
90140 id: '/_chat/$threadId'
......@@ -98,20 +148,33 @@ declare module '@tanstack/react-router' {
98148
99149interface ChatRouteChildren {
100150 ChatThreadIdRoute: typeof ChatThreadIdRoute
101 ChatSettingsRoute: typeof ChatSettingsRoute
102151 ChatIndexRoute: typeof ChatIndexRoute
103152}
104153
105154const ChatRouteChildren: ChatRouteChildren = {
106155 ChatThreadIdRoute: ChatThreadIdRoute,
107 ChatSettingsRoute: ChatSettingsRoute,
108156 ChatIndexRoute: ChatIndexRoute,
109157}
110158
111159const ChatRouteWithChildren = ChatRoute._addFileChildren(ChatRouteChildren)
112160
161interface SettingsRouteChildren {
162 SettingsArchivedRoute: typeof SettingsArchivedRoute
163 SettingsGeneralRoute: typeof SettingsGeneralRoute
164}
165
166const SettingsRouteChildren: SettingsRouteChildren = {
167 SettingsArchivedRoute: SettingsArchivedRoute,
168 SettingsGeneralRoute: SettingsGeneralRoute,
169}
170
171const SettingsRouteWithChildren = SettingsRoute._addFileChildren(
172 SettingsRouteChildren,
173)
174
113175const rootRouteChildren: RootRouteChildren = {
114176 ChatRoute: ChatRouteWithChildren,
177 SettingsRoute: SettingsRouteWithChildren,
115178}
116179export const routeTree = rootRouteImport
117180 ._addFileChildren(rootRouteChildren)
apps/web/src/routes/__root.tsx+4-1
......@@ -11,6 +11,7 @@ import { QueryClient, useQueryClient } from "@tanstack/react-query";
1111import { Throttler } from "@tanstack/react-pacer";
1212
1313import { APP_DISPLAY_NAME } from "../branding";
14import { AppSidebarLayout } from "../components/AppSidebarLayout";
1415import { Button } from "../components/ui/button";
1516import { AnchoredToastProvider, ToastProvider, toastManager } from "../components/ui/toast";
1617import { resolveAndPersistPreferredEditor } from "../editorPreferences";
......@@ -54,7 +55,9 @@ function RootRouteView() {
5455 <AnchoredToastProvider>
5556 <EventRouter />
5657 <DesktopProjectBootstrap />
57 <Outlet />
58 <AppSidebarLayout>
59 <Outlet />
60 </AppSidebarLayout>
5861 </AnchoredToastProvider>
5962 </ToastProvider>
6063 );
apps/web/src/routes/_chat.settings.tsx deleted-1313
......@@ -1,1313 +0,0 @@
1import { createFileRoute } from "@tanstack/react-router";
2import { useQuery, useQueryClient } from "@tanstack/react-query";
3import {
4 ChevronDownIcon,
5 InfoIcon,
6 LoaderIcon,
7 PlusIcon,
8 RefreshCwIcon,
9 RotateCcwIcon,
10 Undo2Icon,
11 XIcon,
12} from "lucide-react";
13import { type ReactNode, useCallback, useEffect, useRef, useState } from "react";
14import {
15 PROVIDER_DISPLAY_NAMES,
16 type ProviderKind,
17 type ServerProvider,
18 type ServerProviderModel,
19} from "@t3tools/contracts";
20import { normalizeModelSlug } from "@t3tools/shared/model";
21import { useSettings, useUpdateSettings } from "../hooks/useSettings";
22import {
23 getCustomModelOptionsByProvider,
24 MAX_CUSTOM_MODEL_LENGTH,
25 resolveAppModelSelectionState,
26} from "../modelSelection";
27import { APP_VERSION } from "../branding";
28import { Button } from "../components/ui/button";
29import { Collapsible, CollapsibleContent } from "../components/ui/collapsible";
30import { Input } from "../components/ui/input";
31import {
32 Select,
33 SelectItem,
34 SelectPopup,
35 SelectTrigger,
36 SelectValue,
37} from "../components/ui/select";
38import { SidebarTrigger } from "../components/ui/sidebar";
39import { Switch } from "../components/ui/switch";
40import { ProviderModelPicker } from "../components/chat/ProviderModelPicker";
41import { TraitsPicker } from "../components/chat/TraitsPicker";
42import { SidebarInset } from "../components/ui/sidebar";
43import { Tooltip, TooltipPopup, TooltipTrigger } from "../components/ui/tooltip";
44import { resolveAndPersistPreferredEditor } from "../editorPreferences";
45import { isElectron } from "../env";
46import { useTheme } from "../hooks/useTheme";
47import { serverConfigQueryOptions, serverQueryKeys } from "../lib/serverReactQuery";
48import { cn } from "../lib/utils";
49import { formatRelativeTime } from "../timestampFormat";
50import { ensureNativeApi, readNativeApi } from "../nativeApi";
51import { DEFAULT_UNIFIED_SETTINGS } from "@t3tools/contracts/settings";
52import { Equal } from "effect";
53
54const THEME_OPTIONS = [
55 {
56 value: "system",
57 label: "System",
58 description: "Match your OS appearance setting.",
59 },
60 {
61 value: "light",
62 label: "Light",
63 description: "Always use the light theme.",
64 },
65 {
66 value: "dark",
67 label: "Dark",
68 description: "Always use the dark theme.",
69 },
70] as const;
71
72const TIMESTAMP_FORMAT_LABELS = {
73 locale: "System default",
74 "12-hour": "12-hour",
75 "24-hour": "24-hour",
76} as const;
77
78const EMPTY_SERVER_PROVIDERS: ReadonlyArray<ServerProvider> = [];
79
80type InstallProviderSettings = {
81 provider: ProviderKind;
82 title: string;
83 binaryPlaceholder: string;
84 binaryDescription: ReactNode;
85 homePathKey?: "codexHomePath";
86 homePlaceholder?: string;
87 homeDescription?: ReactNode;
88};
89
90const PROVIDER_SETTINGS: readonly InstallProviderSettings[] = [
91 {
92 provider: "codex",
93 title: "Codex",
94 binaryPlaceholder: "Codex binary path",
95 binaryDescription: "Path to the Codex binary",
96 homePathKey: "codexHomePath",
97 homePlaceholder: "CODEX_HOME",
98 homeDescription: "Optional custom Codex home and config directory.",
99 },
100 {
101 provider: "claudeAgent",
102 title: "Claude",
103 binaryPlaceholder: "Claude binary path",
104 binaryDescription: "Path to the Claude binary",
105 },
106];
107
108const PROVIDER_STATUS_STYLES = {
109 disabled: {
110 dot: "bg-amber-400",
111 badge: "warning" as const,
112 },
113 error: {
114 dot: "bg-destructive",
115 badge: "error" as const,
116 },
117 ready: {
118 dot: "bg-success",
119 badge: "success" as const,
120 },
121 warning: {
122 dot: "bg-warning",
123 badge: "warning" as const,
124 },
125} as const;
126
127function getProviderSummary(provider: ServerProvider | undefined): {
128 readonly headline: string;
129 readonly detail: string | null;
130} {
131 if (!provider) {
132 return {
133 headline: "Checking provider status",
134 detail: "Waiting for the server to report installation and authentication details.",
135 };
136 }
137 if (!provider.enabled) {
138 return {
139 headline: "Disabled",
140 detail:
141 provider.message ?? "This provider is installed but disabled for new sessions in T3 Code.",
142 };
143 }
144 if (!provider.installed) {
145 return {
146 headline: "Not found",
147 detail: provider.message ?? "CLI not detected on PATH.",
148 };
149 }
150 if (provider.authStatus === "authenticated") {
151 return {
152 headline: "Authenticated",
153 detail: provider.message ?? null,
154 };
155 }
156 if (provider.authStatus === "unauthenticated") {
157 return {
158 headline: "Not authenticated",
159 detail: provider.message ?? null,
160 };
161 }
162 if (provider.status === "warning") {
163 return {
164 headline: "Needs attention",
165 detail:
166 provider.message ?? "The provider is installed, but the server could not fully verify it.",
167 };
168 }
169 if (provider.status === "error") {
170 return {
171 headline: "Unavailable",
172 detail: provider.message ?? "The provider failed its startup checks.",
173 };
174 }
175 return {
176 headline: "Available",
177 detail: provider.message ?? "Installed and ready, but authentication could not be verified.",
178 };
179}
180
181function getProviderVersionLabel(version: string | null | undefined): string | null {
182 if (!version) return null;
183 return version.startsWith("v") ? version : `v${version}`;
184}
185
186/** Returns a timestamp that updates on an interval, forcing re-renders to keep relative times fresh. */
187function useRelativeTimeTick(intervalMs = 1_000): number {
188 const [tick, setTick] = useState(() => Date.now());
189 useEffect(() => {
190 const id = setInterval(() => setTick(Date.now()), intervalMs);
191 return () => clearInterval(id);
192 }, [intervalMs]);
193 return tick;
194}
195
196function SettingsSection({
197 title,
198 headerAction,
199 children,
200}: {
201 title: string;
202 headerAction?: ReactNode;
203 children: ReactNode;
204}) {
205 return (
206 <section className="space-y-3">
207 <div className="flex items-center justify-between">
208 <h2 className="text-[11px] font-medium uppercase tracking-[0.14em] text-muted-foreground">
209 {title}
210 </h2>
211 {headerAction}
212 </div>
213 <div className="relative overflow-hidden rounded-2xl border bg-card not-dark:bg-clip-padding text-card-foreground shadow-xs/5 before:pointer-events-none before:absolute before:inset-0 before:rounded-[calc(var(--radius-2xl)-1px)] before:shadow-[0_1px_--theme(--color-black/4%)] dark:before:shadow-[0_-1px_--theme(--color-white/6%)]">
214 {children}
215 </div>
216 </section>
217 );
218}
219
220function SettingsRow({
221 title,
222 description,
223 status,
224 resetAction,
225 control,
226 children,
227}: {
228 title: string;
229 description: string;
230 status?: ReactNode;
231 resetAction?: ReactNode;
232 control?: ReactNode;
233 children?: ReactNode;
234}) {
235 return (
236 <div
237 className="border-t border-border px-4 py-4 first:border-t-0 sm:px-5"
238 data-slot="settings-row"
239 >
240 <div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
241 <div className="min-w-0 flex-1 space-y-1">
242 <div className="flex min-h-5 items-center gap-1.5">
243 <h3 className="text-sm font-medium text-foreground">{title}</h3>
244 <span className="inline-flex h-5 w-5 shrink-0 items-center justify-center">
245 {resetAction}
246 </span>
247 </div>
248 <p className="text-xs text-muted-foreground">{description}</p>
249 {status ? <div className="pt-1 text-[11px] text-muted-foreground">{status}</div> : null}
250 </div>
251 {control ? (
252 <div className="flex w-full shrink-0 items-center gap-2 sm:w-auto sm:justify-end">
253 {control}
254 </div>
255 ) : null}
256 </div>
257 {children}
258 </div>
259 );
260}
261
262function SettingResetButton({ label, onClick }: { label: string; onClick: () => void }) {
263 return (
264 <Tooltip>
265 <TooltipTrigger
266 render={
267 <Button
268 size="icon-xs"
269 variant="ghost"
270 aria-label={`Reset ${label} to default`}
271 className="size-5 rounded-sm p-0 text-muted-foreground hover:text-foreground"
272 onClick={(event) => {
273 event.stopPropagation();
274 onClick();
275 }}
276 >
277 <Undo2Icon className="size-3" />
278 </Button>
279 }
280 />
281 <TooltipPopup side="top">Reset to default</TooltipPopup>
282 </Tooltip>
283 );
284}
285
286function SettingsRouteView() {
287 const { theme, setTheme } = useTheme();
288 const settings = useSettings();
289 const { updateSettings, resetSettings } = useUpdateSettings();
290 const serverConfigQuery = useQuery(serverConfigQueryOptions());
291 const [isOpeningKeybindings, setIsOpeningKeybindings] = useState(false);
292 const [openKeybindingsError, setOpenKeybindingsError] = useState<string | null>(null);
293 const [openProviderDetails, setOpenProviderDetails] = useState<Record<ProviderKind, boolean>>({
294 codex: Boolean(
295 settings.providers.codex.binaryPath !== DEFAULT_UNIFIED_SETTINGS.providers.codex.binaryPath ||
296 settings.providers.codex.homePath !== DEFAULT_UNIFIED_SETTINGS.providers.codex.homePath ||
297 settings.providers.codex.customModels.length > 0,
298 ),
299 claudeAgent: Boolean(
300 settings.providers.claudeAgent.binaryPath !==
301 DEFAULT_UNIFIED_SETTINGS.providers.claudeAgent.binaryPath ||
302 settings.providers.claudeAgent.customModels.length > 0,
303 ),
304 });
305 const [customModelInputByProvider, setCustomModelInputByProvider] = useState<
306 Record<ProviderKind, string>
307 >({
308 codex: "",
309 claudeAgent: "",
310 });
311 const [customModelErrorByProvider, setCustomModelErrorByProvider] = useState<
312 Partial<Record<ProviderKind, string | null>>
313 >({});
314 const [isRefreshingProviders, setIsRefreshingProviders] = useState(false);
315 const refreshingRef = useRef(false);
316 const queryClient = useQueryClient();
317 useRelativeTimeTick();
318
319 const refreshProviders = useCallback(() => {
320 if (refreshingRef.current) return;
321 refreshingRef.current = true;
322 setIsRefreshingProviders(true);
323 const api = ensureNativeApi();
324 api.server
325 .refreshProviders()
326 .then(() => queryClient.invalidateQueries({ queryKey: serverQueryKeys.config() }))
327 .catch((error: unknown) => {
328 console.warn("Failed to refresh providers", error);
329 })
330 .finally(() => {
331 refreshingRef.current = false;
332 setIsRefreshingProviders(false);
333 });
334 }, [queryClient]);
335
336 const modelListRefs = useRef<Partial<Record<ProviderKind, HTMLDivElement | null>>>({});
337
338 const codexHomePath = settings.providers.codex.homePath;
339 const keybindingsConfigPath = serverConfigQuery.data?.keybindingsConfigPath ?? null;
340 const availableEditors = serverConfigQuery.data?.availableEditors;
341 const serverProviders = serverConfigQuery.data?.providers ?? EMPTY_SERVER_PROVIDERS;
342
343 const textGenerationModelSelection = resolveAppModelSelectionState(settings, serverProviders);
344 const textGenProvider = textGenerationModelSelection.provider;
345 const textGenModel = textGenerationModelSelection.model;
346 const textGenModelOptions = textGenerationModelSelection.options;
347 const gitModelOptionsByProvider = getCustomModelOptionsByProvider(
348 settings,
349 serverProviders,
350 textGenProvider,
351 textGenModel,
352 );
353 const areProviderSettingsDirty = PROVIDER_SETTINGS.some((providerSettings) => {
354 const currentSettings = settings.providers[providerSettings.provider];
355 const defaultSettings = DEFAULT_UNIFIED_SETTINGS.providers[providerSettings.provider];
356 return !Equal.equals(currentSettings, defaultSettings);
357 });
358 const isGitWritingModelDirty = !Equal.equals(
359 settings.textGenerationModelSelection ?? null,
360 DEFAULT_UNIFIED_SETTINGS.textGenerationModelSelection ?? null,
361 );
362 const changedSettingLabels = [
363 ...(theme !== "system" ? ["Theme"] : []),
364 ...(settings.timestampFormat !== DEFAULT_UNIFIED_SETTINGS.timestampFormat
365 ? ["Time format"]
366 : []),
367 ...(settings.diffWordWrap !== DEFAULT_UNIFIED_SETTINGS.diffWordWrap
368 ? ["Diff line wrapping"]
369 : []),
370 ...(settings.enableAssistantStreaming !== DEFAULT_UNIFIED_SETTINGS.enableAssistantStreaming
371 ? ["Assistant output"]
372 : []),
373 ...(settings.defaultThreadEnvMode !== DEFAULT_UNIFIED_SETTINGS.defaultThreadEnvMode
374 ? ["New thread mode"]
375 : []),
376 ...(settings.confirmThreadDelete !== DEFAULT_UNIFIED_SETTINGS.confirmThreadDelete
377 ? ["Delete confirmation"]
378 : []),
379 ...(isGitWritingModelDirty ? ["Git writing model"] : []),
380 ...(areProviderSettingsDirty ? ["Providers"] : []),
381 ];
382
383 const openKeybindingsFile = useCallback(() => {
384 if (!keybindingsConfigPath) return;
385 setOpenKeybindingsError(null);
386 setIsOpeningKeybindings(true);
387 const api = ensureNativeApi();
388 const editor = resolveAndPersistPreferredEditor(availableEditors ?? []);
389 if (!editor) {
390 setOpenKeybindingsError("No available editors found.");
391 setIsOpeningKeybindings(false);
392 return;
393 }
394 void api.shell
395 .openInEditor(keybindingsConfigPath, editor)
396 .catch((error) => {
397 setOpenKeybindingsError(
398 error instanceof Error ? error.message : "Unable to open keybindings file.",
399 );
400 })
401 .finally(() => {
402 setIsOpeningKeybindings(false);
403 });
404 }, [availableEditors, keybindingsConfigPath]);
405
406 const addCustomModel = useCallback(
407 (provider: ProviderKind) => {
408 const customModelInput = customModelInputByProvider[provider];
409 const customModels = settings.providers[provider].customModels;
410 const normalized = normalizeModelSlug(customModelInput, provider);
411 if (!normalized) {
412 setCustomModelErrorByProvider((existing) => ({
413 ...existing,
414 [provider]: "Enter a model slug.",
415 }));
416 return;
417 }
418 if (
419 serverProviders
420 .find((candidate) => candidate.provider === provider)
421 ?.models.some((option) => !option.isCustom && option.slug === normalized)
422 ) {
423 setCustomModelErrorByProvider((existing) => ({
424 ...existing,
425 [provider]: "That model is already built in.",
426 }));
427 return;
428 }
429 if (normalized.length > MAX_CUSTOM_MODEL_LENGTH) {
430 setCustomModelErrorByProvider((existing) => ({
431 ...existing,
432 [provider]: `Model slugs must be ${MAX_CUSTOM_MODEL_LENGTH} characters or less.`,
433 }));
434 return;
435 }
436 if (customModels.includes(normalized)) {
437 setCustomModelErrorByProvider((existing) => ({
438 ...existing,
439 [provider]: "That custom model is already saved.",
440 }));
441 return;
442 }
443
444 updateSettings({
445 providers: {
446 ...settings.providers,
447 [provider]: {
448 ...settings.providers[provider],
449 customModels: [...customModels, normalized],
450 },
451 },
452 });
453 setCustomModelInputByProvider((existing) => ({
454 ...existing,
455 [provider]: "",
456 }));
457 setCustomModelErrorByProvider((existing) => ({
458 ...existing,
459 [provider]: null,
460 }));
461 // Watch for DOM changes (server may push updated model list) and scroll to bottom
462 const el = modelListRefs.current[provider];
463 if (el) {
464 const scrollToEnd = () => el.scrollTo({ top: el.scrollHeight, behavior: "smooth" });
465 // Immediate scroll for the optimistic update
466 requestAnimationFrame(scrollToEnd);
467 // Also observe mutations for when the server pushes an updated list
468 const observer = new MutationObserver(() => {
469 scrollToEnd();
470 observer.disconnect();
471 });
472 observer.observe(el, { childList: true, subtree: true });
473 // Clean up observer after a reasonable window
474 setTimeout(() => observer.disconnect(), 2000);
475 }
476 },
477 [customModelInputByProvider, serverProviders, settings, updateSettings],
478 );
479
480 const removeCustomModel = useCallback(
481 (provider: ProviderKind, slug: string) => {
482 const customModels = settings.providers[provider].customModels;
483 updateSettings({
484 providers: {
485 ...settings.providers,
486 [provider]: {
487 ...settings.providers[provider],
488 customModels: customModels.filter((model) => model !== slug),
489 },
490 },
491 });
492 setCustomModelErrorByProvider((existing) => ({
493 ...existing,
494 [provider]: null,
495 }));
496 },
497 [settings, updateSettings],
498 );
499
500 const providerCards = PROVIDER_SETTINGS.map((providerSettings) => {
501 const liveProvider = serverProviders.find(
502 (candidate) => candidate.provider === providerSettings.provider,
503 );
504 const providerConfig = settings.providers[providerSettings.provider];
505 const defaultProviderConfig = DEFAULT_UNIFIED_SETTINGS.providers[providerSettings.provider];
506 const statusKey = liveProvider?.status ?? (providerConfig.enabled ? "warning" : "disabled");
507 const statusStyle = PROVIDER_STATUS_STYLES[statusKey];
508 const summary = getProviderSummary(liveProvider);
509 const models: ReadonlyArray<ServerProviderModel> =
510 liveProvider?.models ??
511 providerConfig.customModels.map((slug) => ({
512 slug,
513 name: slug,
514 isCustom: true,
515 capabilities: null,
516 }));
517 const binaryPathValue = providerConfig.binaryPath;
518 const isDirty = !Equal.equals(providerConfig, defaultProviderConfig);
519
520 return {
521 provider: providerSettings.provider,
522 title: providerSettings.title,
523 binaryPlaceholder: providerSettings.binaryPlaceholder,
524 binaryDescription: providerSettings.binaryDescription,
525 homePathKey: providerSettings.homePathKey,
526 homePlaceholder: providerSettings.homePlaceholder,
527 homeDescription: providerSettings.homeDescription,
528 binaryPathValue,
529 isDirty,
530 liveProvider,
531 models,
532 providerConfig,
533 statusKey,
534 statusStyle,
535 summary,
536 versionLabel: getProviderVersionLabel(liveProvider?.version),
537 };
538 });
539
540 async function restoreDefaults() {
541 if (changedSettingLabels.length === 0) return;
542
543 const api = readNativeApi();
544 const confirmed = await (api ?? ensureNativeApi()).dialogs.confirm(
545 ["Restore default settings?", `This will reset: ${changedSettingLabels.join(", ")}.`].join(
546 "\n",
547 ),
548 );
549 if (!confirmed) return;
550
551 setTheme("system");
552 resetSettings();
553 setOpenProviderDetails({
554 codex: false,
555 claudeAgent: false,
556 });
557 setCustomModelInputByProvider({
558 codex: "",
559 claudeAgent: "",
560 });
561 setCustomModelErrorByProvider({});
562 }
563
564 return (
565 <SidebarInset className="h-dvh min-h-0 overflow-hidden overscroll-y-none bg-background text-foreground isolate">
566 <div className="flex min-h-0 min-w-0 flex-1 flex-col bg-background text-foreground">
567 {!isElectron && (
568 <header className="border-b border-border px-3 py-2 sm:px-5">
569 <div className="flex items-center gap-2">
570 <SidebarTrigger className="size-7 shrink-0 md:hidden" />
571 <span className="text-sm font-medium text-foreground">Settings</span>
572 <div className="ms-auto flex items-center gap-2">
573 <Button
574 size="xs"
575 variant="outline"
576 disabled={changedSettingLabels.length === 0}
577 onClick={() => void restoreDefaults()}
578 >
579 <RotateCcwIcon className="size-3.5" />
580 Restore defaults
581 </Button>
582 </div>
583 </div>
584 </header>
585 )}
586
587 {isElectron && (
588 <div className="drag-region flex h-[52px] shrink-0 items-center border-b border-border px-5">
589 <span className="text-xs font-medium tracking-wide text-muted-foreground/70">
590 Settings
591 </span>
592 <div className="ms-auto flex items-center gap-2">
593 <Button
594 size="xs"
595 variant="outline"
596 disabled={changedSettingLabels.length === 0}
597 onClick={() => void restoreDefaults()}
598 >
599 <RotateCcwIcon className="size-3.5" />
600 Restore defaults
601 </Button>
602 </div>
603 </div>
604 )}
605
606 <div className="flex-1 overflow-y-auto p-6">
607 <div className="mx-auto flex w-full max-w-2xl flex-col gap-6">
608 <SettingsSection title="General">
609 <SettingsRow
610 title="Theme"
611 description="Choose how T3 Code looks across the app."
612 resetAction={
613 theme !== "system" ? (
614 <SettingResetButton label="theme" onClick={() => setTheme("system")} />
615 ) : null
616 }
617 control={
618 <Select
619 value={theme}
620 onValueChange={(value) => {
621 if (value !== "system" && value !== "light" && value !== "dark") return;
622 setTheme(value);
623 }}
624 >
625 <SelectTrigger className="w-full sm:w-40" aria-label="Theme preference">
626 <SelectValue>
627 {THEME_OPTIONS.find((option) => option.value === theme)?.label ?? "System"}
628 </SelectValue>
629 </SelectTrigger>
630 <SelectPopup align="end" alignItemWithTrigger={false}>
631 {THEME_OPTIONS.map((option) => (
632 <SelectItem hideIndicator key={option.value} value={option.value}>
633 {option.label}
634 </SelectItem>
635 ))}
636 </SelectPopup>
637 </Select>
638 }
639 />
640
641 <SettingsRow
642 title="Time format"
643 description="System default follows your browser or OS clock preference."
644 resetAction={
645 settings.timestampFormat !== DEFAULT_UNIFIED_SETTINGS.timestampFormat ? (
646 <SettingResetButton
647 label="time format"
648 onClick={() =>
649 updateSettings({
650 timestampFormat: DEFAULT_UNIFIED_SETTINGS.timestampFormat,
651 })
652 }
653 />
654 ) : null
655 }
656 control={
657 <Select
658 value={settings.timestampFormat}
659 onValueChange={(value) => {
660 if (value !== "locale" && value !== "12-hour" && value !== "24-hour") {
661 return;
662 }
663 updateSettings({
664 timestampFormat: value,
665 });
666 }}
667 >
668 <SelectTrigger className="w-full sm:w-40" aria-label="Timestamp format">
669 <SelectValue>{TIMESTAMP_FORMAT_LABELS[settings.timestampFormat]}</SelectValue>
670 </SelectTrigger>
671 <SelectPopup align="end" alignItemWithTrigger={false}>
672 <SelectItem hideIndicator value="locale">
673 {TIMESTAMP_FORMAT_LABELS.locale}
674 </SelectItem>
675 <SelectItem hideIndicator value="12-hour">
676 {TIMESTAMP_FORMAT_LABELS["12-hour"]}
677 </SelectItem>
678 <SelectItem hideIndicator value="24-hour">
679 {TIMESTAMP_FORMAT_LABELS["24-hour"]}
680 </SelectItem>
681 </SelectPopup>
682 </Select>
683 }
684 />
685
686 <SettingsRow
687 title="Diff line wrapping"
688 description="Set the default wrap state when the diff panel opens. The in-panel wrap toggle only affects the current diff session."
689 resetAction={
690 settings.diffWordWrap !== DEFAULT_UNIFIED_SETTINGS.diffWordWrap ? (
691 <SettingResetButton
692 label="diff line wrapping"
693 onClick={() =>
694 updateSettings({
695 diffWordWrap: DEFAULT_UNIFIED_SETTINGS.diffWordWrap,
696 })
697 }
698 />
699 ) : null
700 }
701 control={
702 <Switch
703 checked={settings.diffWordWrap}
704 onCheckedChange={(checked) =>
705 updateSettings({
706 diffWordWrap: Boolean(checked),
707 })
708 }
709 aria-label="Wrap diff lines by default"
710 />
711 }
712 />
713
714 <SettingsRow
715 title="Assistant output"
716 description="Show token-by-token output while a response is in progress."
717 resetAction={
718 settings.enableAssistantStreaming !==
719 DEFAULT_UNIFIED_SETTINGS.enableAssistantStreaming ? (
720 <SettingResetButton
721 label="assistant output"
722 onClick={() =>
723 updateSettings({
724 enableAssistantStreaming:
725 DEFAULT_UNIFIED_SETTINGS.enableAssistantStreaming,
726 })
727 }
728 />
729 ) : null
730 }
731 control={
732 <Switch
733 checked={settings.enableAssistantStreaming}
734 onCheckedChange={(checked) =>
735 updateSettings({
736 enableAssistantStreaming: Boolean(checked),
737 })
738 }
739 aria-label="Stream assistant messages"
740 />
741 }
742 />
743
744 <SettingsRow
745 title="New threads"
746 description="Pick the default workspace mode for newly created draft threads."
747 resetAction={
748 settings.defaultThreadEnvMode !==
749 DEFAULT_UNIFIED_SETTINGS.defaultThreadEnvMode ? (
750 <SettingResetButton
751 label="new threads"
752 onClick={() =>
753 updateSettings({
754 defaultThreadEnvMode: DEFAULT_UNIFIED_SETTINGS.defaultThreadEnvMode,
755 })
756 }
757 />
758 ) : null
759 }
760 control={
761 <Select
762 value={settings.defaultThreadEnvMode}
763 onValueChange={(value) => {
764 if (value !== "local" && value !== "worktree") return;
765 updateSettings({
766 defaultThreadEnvMode: value,
767 });
768 }}
769 >
770 <SelectTrigger className="w-full sm:w-44" aria-label="Default thread mode">
771 <SelectValue>
772 {settings.defaultThreadEnvMode === "worktree" ? "New worktree" : "Local"}
773 </SelectValue>
774 </SelectTrigger>
775 <SelectPopup align="end" alignItemWithTrigger={false}>
776 <SelectItem hideIndicator value="local">
777 Local
778 </SelectItem>
779 <SelectItem hideIndicator value="worktree">
780 New worktree
781 </SelectItem>
782 </SelectPopup>
783 </Select>
784 }
785 />
786
787 <SettingsRow
788 title="Delete confirmation"
789 description="Ask before deleting a thread and its chat history."
790 resetAction={
791 settings.confirmThreadDelete !== DEFAULT_UNIFIED_SETTINGS.confirmThreadDelete ? (
792 <SettingResetButton
793 label="delete confirmation"
794 onClick={() =>
795 updateSettings({
796 confirmThreadDelete: DEFAULT_UNIFIED_SETTINGS.confirmThreadDelete,
797 })
798 }
799 />
800 ) : null
801 }
802 control={
803 <Switch
804 checked={settings.confirmThreadDelete}
805 onCheckedChange={(checked) =>
806 updateSettings({
807 confirmThreadDelete: Boolean(checked),
808 })
809 }
810 aria-label="Confirm thread deletion"
811 />
812 }
813 />
814 <SettingsRow
815 title="Text generation model"
816 description="Configure the model used for text generation (commit messages, PR content etc.)"
817 resetAction={
818 JSON.stringify(settings.textGenerationModelSelection ?? null) !==
819 JSON.stringify(DEFAULT_UNIFIED_SETTINGS.textGenerationModelSelection ?? null) ? (
820 <SettingResetButton
821 label="text generation model"
822 onClick={() => {
823 updateSettings({
824 textGenerationModelSelection:
825 DEFAULT_UNIFIED_SETTINGS.textGenerationModelSelection,
826 });
827 }}
828 />
829 ) : null
830 }
831 control={
832 <div className="flex flex-wrap items-center justify-end gap-1.5">
833 <ProviderModelPicker
834 provider={textGenProvider}
835 model={textGenModel}
836 lockedProvider={null}
837 providers={serverProviders}
838 modelOptionsByProvider={gitModelOptionsByProvider}
839 triggerVariant="outline"
840 triggerClassName="min-w-0 max-w-none shrink-0 text-foreground/90 hover:text-foreground"
841 onProviderModelChange={(provider, model) => {
842 updateSettings({
843 textGenerationModelSelection: resolveAppModelSelectionState(
844 {
845 ...settings,
846 textGenerationModelSelection: { provider, model },
847 },
848 serverProviders,
849 ),
850 });
851 }}
852 />
853 <TraitsPicker
854 provider={textGenProvider}
855 models={
856 serverProviders.find((provider) => provider.provider === textGenProvider)
857 ?.models ?? []
858 }
859 model={textGenModel}
860 prompt=""
861 onPromptChange={() => {}}
862 modelOptions={textGenModelOptions}
863 allowPromptInjectedEffort={false}
864 triggerVariant="outline"
865 triggerClassName="min-w-0 max-w-none shrink-0 text-foreground/90 hover:text-foreground"
866 onModelOptionsChange={(nextOptions) => {
867 updateSettings({
868 textGenerationModelSelection: resolveAppModelSelectionState(
869 {
870 ...settings,
871 textGenerationModelSelection: {
872 provider: textGenProvider,
873 model: textGenModel,
874 ...(nextOptions ? { options: nextOptions } : {}),
875 },
876 },
877 serverProviders,
878 ),
879 });
880 }}
881 />
882 </div>
883 }
884 />
885 </SettingsSection>
886
887 <SettingsSection
888 title="Providers"
889 headerAction={
890 <div className="flex items-center gap-1.5">
891 {serverProviders.length > 0 ? (
892 <span className="text-[11px] text-muted-foreground/60">
893 {(() => {
894 const rel = formatRelativeTime(
895 serverProviders.reduce(
896 (latest, provider) =>
897 provider.checkedAt > latest ? provider.checkedAt : latest,
898 serverProviders[0]!.checkedAt,
899 ),
900 );
901 return rel.suffix ? (
902 <>
903 Checked <span className="font-mono tabular-nums">{rel.value}</span>{" "}
904 {rel.suffix}
905 </>
906 ) : (
907 <>Checked {rel.value}</>
908 );
909 })()}
910 </span>
911 ) : null}
912 <Tooltip>
913 <TooltipTrigger
914 render={
915 <Button
916 size="icon-xs"
917 variant="ghost"
918 className="size-5 rounded-sm p-0 text-muted-foreground hover:text-foreground"
919 disabled={isRefreshingProviders}
920 onClick={() => void refreshProviders()}
921 aria-label="Refresh provider status"
922 >
923 {isRefreshingProviders ? (
924 <LoaderIcon className="size-3 animate-spin" />
925 ) : (
926 <RefreshCwIcon className="size-3" />
927 )}
928 </Button>
929 }
930 />
931 <TooltipPopup side="top">Refresh provider status</TooltipPopup>
932 </Tooltip>
933 </div>
934 }
935 >
936 {providerCards.map((providerCard) => {
937 const customModelInput = customModelInputByProvider[providerCard.provider];
938 const customModelError = customModelErrorByProvider[providerCard.provider] ?? null;
939 const providerDisplayName =
940 PROVIDER_DISPLAY_NAMES[providerCard.provider] ?? providerCard.title;
941
942 return (
943 <div
944 key={providerCard.provider}
945 className="border-t border-border first:border-t-0"
946 data-slot="settings-row"
947 >
948 <div className="px-4 py-4 sm:px-5">
949 <div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
950 <div className="min-w-0 flex-1 space-y-1">
951 <div className="flex min-h-5 items-center gap-1.5">
952 <span
953 className={cn(
954 "size-2 shrink-0 rounded-full",
955 providerCard.statusStyle.dot,
956 )}
957 />
958 <h3 className="text-sm font-medium text-foreground">
959 {providerDisplayName}
960 </h3>
961 {providerCard.versionLabel ? (
962 <code className="text-xs text-muted-foreground">
963 {providerCard.versionLabel}
964 </code>
965 ) : null}
966 <span className="inline-flex h-5 w-5 shrink-0 items-center justify-center">
967 {providerCard.isDirty ? (
968 <SettingResetButton
969 label={`${providerDisplayName} provider settings`}
970 onClick={() => {
971 updateSettings({
972 providers: {
973 ...settings.providers,
974 [providerCard.provider]:
975 DEFAULT_UNIFIED_SETTINGS.providers[providerCard.provider],
976 },
977 });
978 setCustomModelErrorByProvider((existing) => ({
979 ...existing,
980 [providerCard.provider]: null,
981 }));
982 }}
983 />
984 ) : null}
985 </span>
986 </div>
987 <p className="text-xs text-muted-foreground">
988 {providerCard.summary.headline}
989 {providerCard.summary.detail
990 ? ` — ${providerCard.summary.detail}`
991 : null}
992 </p>
993 </div>
994 <div className="flex w-full shrink-0 items-center gap-2 sm:w-auto sm:justify-end">
995 <Button
996 size="sm"
997 variant="ghost"
998 className="h-7 px-2 text-xs text-muted-foreground hover:text-foreground"
999 onClick={() =>
1000 setOpenProviderDetails((existing) => ({
1001 ...existing,
1002 [providerCard.provider]: !existing[providerCard.provider],
1003 }))
1004 }
1005 aria-label={`Toggle ${providerDisplayName} details`}
1006 >
1007 <ChevronDownIcon
1008 className={cn(
1009 "size-3.5 transition-transform",
1010 openProviderDetails[providerCard.provider] && "rotate-180",
1011 )}
1012 />
1013 </Button>
1014 <Switch
1015 checked={providerCard.providerConfig.enabled}
1016 onCheckedChange={(checked) => {
1017 const isDisabling = !checked;
1018 // The resolved provider accounts for both explicit
1019 // selection and the implicit default (codex).
1020 const resolvedProvider = textGenProvider;
1021 // When disabling the provider that's currently used for
1022 // text generation, clear the selection so it falls back to
1023 // the next available provider's default model.
1024 const shouldClearModelSelection =
1025 isDisabling && resolvedProvider === providerCard.provider;
1026 updateSettings({
1027 providers: {
1028 ...settings.providers,
1029 [providerCard.provider]: {
1030 ...settings.providers[providerCard.provider],
1031 enabled: Boolean(checked),
1032 },
1033 },
1034 ...(shouldClearModelSelection
1035 ? {
1036 textGenerationModelSelection:
1037 DEFAULT_UNIFIED_SETTINGS.textGenerationModelSelection,
1038 }
1039 : {}),
1040 });
1041 }}
1042 aria-label={`Enable ${providerDisplayName}`}
1043 />
1044 </div>
1045 </div>
1046 </div>
1047
1048 <Collapsible
1049 open={openProviderDetails[providerCard.provider]}
1050 onOpenChange={(open) =>
1051 setOpenProviderDetails((existing) => ({
1052 ...existing,
1053 [providerCard.provider]: open,
1054 }))
1055 }
1056 >
1057 <CollapsibleContent>
1058 <div className="space-y-0">
1059 {/* Binary path */}
1060 <div className="border-t border-border/60 px-4 py-3 sm:px-5">
1061 <label
1062 htmlFor={`provider-install-${providerCard.provider}-binary-path`}
1063 className="block"
1064 >
1065 <span className="text-xs font-medium text-foreground">
1066 {providerDisplayName} binary path
1067 </span>
1068 <Input
1069 id={`provider-install-${providerCard.provider}-binary-path`}
1070 className="mt-1.5"
1071 value={providerCard.binaryPathValue}
1072 onChange={(event) =>
1073 updateSettings({
1074 providers: {
1075 ...settings.providers,
1076 [providerCard.provider]: {
1077 ...settings.providers[providerCard.provider],
1078 binaryPath: event.target.value,
1079 },
1080 },
1081 })
1082 }
1083 placeholder={providerCard.binaryPlaceholder}
1084 spellCheck={false}
1085 />
1086 <span className="mt-1 block text-xs text-muted-foreground">
1087 {providerCard.binaryDescription}
1088 </span>
1089 </label>
1090 </div>
1091
1092 {/* Home path (Codex only) */}
1093 {providerCard.homePathKey ? (
1094 <div className="border-t border-border/60 px-4 py-3 sm:px-5">
1095 <label
1096 htmlFor={`provider-install-${providerCard.homePathKey}`}
1097 className="block"
1098 >
1099 <span className="text-xs font-medium text-foreground">
1100 CODEX_HOME path
1101 </span>
1102 <Input
1103 id={`provider-install-${providerCard.homePathKey}`}
1104 className="mt-1.5"
1105 value={codexHomePath}
1106 onChange={(event) =>
1107 updateSettings({
1108 providers: {
1109 ...settings.providers,
1110 codex: {
1111 ...settings.providers.codex,
1112 homePath: event.target.value,
1113 },
1114 },
1115 })
1116 }
1117 placeholder={providerCard.homePlaceholder}
1118 spellCheck={false}
1119 />
1120 {providerCard.homeDescription ? (
1121 <span className="mt-1 block text-xs text-muted-foreground">
1122 {providerCard.homeDescription}
1123 </span>
1124 ) : null}
1125 </label>
1126 </div>
1127 ) : null}
1128
1129 {/* Models */}
1130 <div className="border-t border-border/60 px-4 py-3 sm:px-5">
1131 <div className="text-xs font-medium text-foreground">Models</div>
1132 <div className="mt-1 text-xs text-muted-foreground">
1133 {providerCard.models.length} model
1134 {providerCard.models.length === 1 ? "" : "s"} available.
1135 </div>
1136 <div
1137 ref={(el) => {
1138 modelListRefs.current[providerCard.provider] = el;
1139 }}
1140 className="mt-2 max-h-40 overflow-y-auto pb-1"
1141 >
1142 {providerCard.models.map((model) => {
1143 const caps = model.capabilities;
1144 const capLabels: string[] = [];
1145 if (caps?.supportsFastMode) capLabels.push("Fast mode");
1146 if (caps?.supportsThinkingToggle) capLabels.push("Thinking");
1147 if (
1148 caps?.reasoningEffortLevels &&
1149 caps.reasoningEffortLevels.length > 0
1150 )
1151 capLabels.push("Reasoning");
1152 const hasDetails =
1153 capLabels.length > 0 || model.name !== model.slug;
1154
1155 return (
1156 <div
1157 key={`${providerCard.provider}:${model.slug}`}
1158 className="flex items-center gap-2 py-1"
1159 >
1160 <span className="min-w-0 truncate text-xs text-foreground/90">
1161 {model.name}
1162 </span>
1163 {hasDetails ? (
1164 <Tooltip>
1165 <TooltipTrigger
1166 render={
1167 <button
1168 type="button"
1169 className="shrink-0 text-muted-foreground/40 transition-colors hover:text-muted-foreground"
1170 aria-label={`Details for ${model.name}`}
1171 />
1172 }
1173 >
1174 <InfoIcon className="size-3" />
1175 </TooltipTrigger>
1176 <TooltipPopup side="top" className="max-w-56">
1177 <div className="space-y-1">
1178 <code className="block text-[11px] text-foreground">
1179 {model.slug}
1180 </code>
1181 {capLabels.length > 0 ? (
1182 <div className="flex flex-wrap gap-x-2 gap-y-0.5">
1183 {capLabels.map((label) => (
1184 <span
1185 key={label}
1186 className="text-[10px] text-muted-foreground"
1187 >
1188 {label}
1189 </span>
1190 ))}
1191 </div>
1192 ) : null}
1193 </div>
1194 </TooltipPopup>
1195 </Tooltip>
1196 ) : null}
1197 {model.isCustom ? (
1198 <div className="ml-auto flex shrink-0 items-center gap-1.5">
1199 <span className="text-[10px] text-muted-foreground">
1200 custom
1201 </span>
1202 <button
1203 type="button"
1204 className="text-muted-foreground transition-colors hover:text-foreground"
1205 aria-label={`Remove ${model.slug}`}
1206 onClick={() =>
1207 removeCustomModel(providerCard.provider, model.slug)
1208 }
1209 >
1210 <XIcon className="size-3" />
1211 </button>
1212 </div>
1213 ) : null}
1214 </div>
1215 );
1216 })}
1217 </div>
1218 <div className="mt-3 flex flex-col gap-2 sm:flex-row">
1219 <Input
1220 id={`custom-model-${providerCard.provider}`}
1221 value={customModelInput}
1222 onChange={(event) => {
1223 const value = event.target.value;
1224 setCustomModelInputByProvider((existing) => ({
1225 ...existing,
1226 [providerCard.provider]: value,
1227 }));
1228 if (customModelError) {
1229 setCustomModelErrorByProvider((existing) => ({
1230 ...existing,
1231 [providerCard.provider]: null,
1232 }));
1233 }
1234 }}
1235 onKeyDown={(event) => {
1236 if (event.key !== "Enter") return;
1237 event.preventDefault();
1238 addCustomModel(providerCard.provider);
1239 }}
1240 placeholder={
1241 providerCard.provider === "codex"
1242 ? "gpt-6.7-codex-ultra-preview"
1243 : "claude-sonnet-5-0"
1244 }
1245 spellCheck={false}
1246 />
1247 <Button
1248 className="shrink-0"
1249 variant="outline"
1250 onClick={() => addCustomModel(providerCard.provider)}
1251 >
1252 <PlusIcon className="size-3.5" />
1253 Add
1254 </Button>
1255 </div>
1256 {customModelError ? (
1257 <p className="mt-2 text-xs text-destructive">{customModelError}</p>
1258 ) : null}
1259 </div>
1260 </div>
1261 </CollapsibleContent>
1262 </Collapsible>
1263 </div>
1264 );
1265 })}
1266 </SettingsSection>
1267
1268 <SettingsSection title="Advanced">
1269 <SettingsRow
1270 title="Keybindings"
1271 description="Open the persisted `keybindings.json` file to edit advanced bindings directly."
1272 status={
1273 <>
1274 <span className="block break-all font-mono text-[11px] text-foreground">
1275 {keybindingsConfigPath ?? "Resolving keybindings path..."}
1276 </span>
1277 {openKeybindingsError ? (
1278 <span className="mt-1 block text-destructive">{openKeybindingsError}</span>
1279 ) : (
1280 <span className="mt-1 block">Opens in your preferred editor.</span>
1281 )}
1282 </>
1283 }
1284 control={
1285 <Button
1286 size="xs"
1287 variant="outline"
1288 disabled={!keybindingsConfigPath || isOpeningKeybindings}
1289 onClick={openKeybindingsFile}
1290 >
1291 {isOpeningKeybindings ? "Opening..." : "Open file"}
1292 </Button>
1293 }
1294 />
1295
1296 <SettingsRow
1297 title="Version"
1298 description="Current application version."
1299 control={
1300 <code className="text-xs font-medium text-muted-foreground">{APP_VERSION}</code>
1301 }
1302 />
1303 </SettingsSection>
1304 </div>
1305 </div>
1306 </div>
1307 </SidebarInset>
1308 );
1309}
1310
1311export const Route = createFileRoute("/_chat/settings")({
1312 component: SettingsRouteView,
1313});
apps/web/src/routes/_chat.tsx+3-40
......@@ -1,9 +1,8 @@
11import { type ResolvedKeybindingsConfig } from "@t3tools/contracts";
22import { useQuery } from "@tanstack/react-query";
3import { Outlet, createFileRoute, useNavigate } from "@tanstack/react-router";
3import { Outlet, createFileRoute } from "@tanstack/react-router";
44import { useEffect } from "react";
55
6import ThreadSidebar from "../components/Sidebar";
76import { useHandleNewThread } from "../hooks/useHandleNewThread";
87import { isTerminalFocused } from "../lib/terminalFocus";
98import { serverConfigQueryOptions } from "../lib/serverReactQuery";
......@@ -12,12 +11,8 @@ import { selectThreadTerminalState, useTerminalStateStore } from "../terminalSta
1211import { useThreadSelectionStore } from "../threadSelectionStore";
1312import { resolveSidebarNewThreadEnvMode } from "~/components/Sidebar.logic";
1413import { useSettings } from "~/hooks/useSettings";
15import { Sidebar, SidebarProvider, SidebarRail } from "~/components/ui/sidebar";
1614
1715const EMPTY_KEYBINDINGS: ResolvedKeybindingsConfig = [];
18const THREAD_SIDEBAR_WIDTH_STORAGE_KEY = "chat_thread_sidebar_width";
19const THREAD_SIDEBAR_MIN_WIDTH = 13 * 16;
20const THREAD_MAIN_CONTENT_MIN_WIDTH = 40 * 16;
2116
2217function ChatRouteGlobalShortcuts() {
2318 const clearSelection = useThreadSelectionStore((state) => state.clearSelection);
......@@ -94,43 +89,11 @@ function ChatRouteGlobalShortcuts() {
9489}
9590
9691function ChatRouteLayout() {
97 const navigate = useNavigate();
98
99 useEffect(() => {
100 const onMenuAction = window.desktopBridge?.onMenuAction;
101 if (typeof onMenuAction !== "function") {
102 return;
103 }
104
105 const unsubscribe = onMenuAction((action) => {
106 if (action !== "open-settings") return;
107 void navigate({ to: "/settings" });
108 });
109
110 return () => {
111 unsubscribe?.();
112 };
113 }, [navigate]);
114
11592 return (
116 <SidebarProvider defaultOpen>
93 <>
11794 <ChatRouteGlobalShortcuts />
118 <Sidebar
119 side="left"
120 collapsible="offcanvas"
121 className="border-r border-border bg-card text-foreground"
122 resizable={{
123 minWidth: THREAD_SIDEBAR_MIN_WIDTH,
124 shouldAcceptWidth: ({ nextWidth, wrapper }) =>
125 wrapper.clientWidth - nextWidth >= THREAD_MAIN_CONTENT_MIN_WIDTH,
126 storageKey: THREAD_SIDEBAR_WIDTH_STORAGE_KEY,
127 }}
128 >
129 <ThreadSidebar />
130 <SidebarRail />
131 </Sidebar>
13295 <Outlet />
133 </SidebarProvider>
96 </>
13497 );
13598}
13699
apps/web/src/routes/settings.archived.tsx created+7
......@@ -0,0 +1,7 @@
1import { createFileRoute } from "@tanstack/react-router";
2
3import { ArchivedThreadsPanel } from "../components/settings/SettingsPanels";
4
5export const Route = createFileRoute("/settings/archived")({
6 component: ArchivedThreadsPanel,
7});
apps/web/src/routes/settings.general.tsx created+7
......@@ -0,0 +1,7 @@
1import { createFileRoute } from "@tanstack/react-router";
2
3import { GeneralSettingsPanel } from "../components/settings/SettingsPanels";
4
5export const Route = createFileRoute("/settings/general")({
6 component: GeneralSettingsPanel,
7});
apps/web/src/routes/settings.tsx created+77
......@@ -0,0 +1,77 @@
1import { RotateCcwIcon } from "lucide-react";
2import { Outlet, createFileRoute, redirect } from "@tanstack/react-router";
3import { useState } from "react";
4
5import { useSettingsRestore } from "../components/settings/SettingsPanels";
6import { Button } from "../components/ui/button";
7import { SidebarInset, SidebarTrigger } from "../components/ui/sidebar";
8import { isElectron } from "../env";
9
10function SettingsContentLayout() {
11 const [restoreSignal, setRestoreSignal] = useState(0);
12 const { changedSettingLabels, restoreDefaults } = useSettingsRestore(() =>
13 setRestoreSignal((value) => value + 1),
14 );
15
16 return (
17 <SidebarInset className="h-dvh min-h-0 overflow-hidden overscroll-y-none bg-background text-foreground isolate">
18 <div className="flex min-h-0 min-w-0 flex-1 flex-col bg-background text-foreground">
19 {!isElectron && (
20 <header className="border-b border-border px-3 py-2 sm:px-5">
21 <div className="flex items-center gap-2">
22 <SidebarTrigger className="size-7 shrink-0 md:hidden" />
23 <span className="text-sm font-medium text-foreground">Settings</span>
24 <div className="ms-auto flex items-center gap-2">
25 <Button
26 size="xs"
27 variant="outline"
28 disabled={changedSettingLabels.length === 0}
29 onClick={() => void restoreDefaults()}
30 >
31 <RotateCcwIcon className="size-3.5" />
32 Restore defaults
33 </Button>
34 </div>
35 </div>
36 </header>
37 )}
38
39 {isElectron && (
40 <div className="drag-region flex h-[52px] shrink-0 items-center border-b border-border px-5">
41 <span className="text-xs font-medium tracking-wide text-muted-foreground/70">
42 Settings
43 </span>
44 <div className="ms-auto flex items-center gap-2">
45 <Button
46 size="xs"
47 variant="outline"
48 disabled={changedSettingLabels.length === 0}
49 onClick={() => void restoreDefaults()}
50 >
51 <RotateCcwIcon className="size-3.5" />
52 Restore defaults
53 </Button>
54 </div>
55 </div>
56 )}
57
58 <div key={restoreSignal} className="min-h-0 flex flex-1 flex-col">
59 <Outlet />
60 </div>
61 </div>
62 </SidebarInset>
63 );
64}
65
66function SettingsRouteLayout() {
67 return <SettingsContentLayout />;
68}
69
70export const Route = createFileRoute("/settings")({
71 beforeLoad: ({ location }) => {
72 if (location.pathname === "/settings") {
73 throw redirect({ to: "/settings/general", replace: true });
74 }
75 },
76 component: SettingsRouteLayout,
77});
apps/web/src/store.test.ts+17
......@@ -29,6 +29,7 @@ function makeThread(overrides: Partial<Thread> = {}): Thread {
2929 proposedPlans: [],
3030 error: null,
3131 createdAt: "2026-02-13T00:00:00.000Z",
32 archivedAt: null,
3233 latestTurn: null,
3334 branch: null,
3435 worktreePath: null,
......@@ -72,6 +73,7 @@ function makeReadModelThread(overrides: Partial<OrchestrationReadModel["threads"
7273 latestTurn: null,
7374 createdAt: "2026-02-27T00:00:00.000Z",
7475 updatedAt: "2026-02-27T00:00:00.000Z",
76 archivedAt: null,
7577 deletedAt: null,
7678 messages: [],
7779 activities: [],
......@@ -270,6 +272,21 @@ describe("store read model sync", () => {
270272 expect(next.threads[0]?.updatedAt).toBe("2026-02-27T00:05:00.000Z");
271273 });
272274
275 it("maps archivedAt from the read model", () => {
276 const initialState = makeState(makeThread());
277 const archivedAt = "2026-02-28T00:00:00.000Z";
278 const next = syncServerReadModel(
279 initialState,
280 makeReadModel(
281 makeReadModelThread({
282 archivedAt,
283 }),
284 ),
285 );
286
287 expect(next.threads[0]?.archivedAt).toBe(archivedAt);
288 });
289
273290 it("preserves the current project order when syncing incoming read model updates", () => {
274291 const project1 = ProjectId.makeUnsafe("project-1");
275292 const project2 = ProjectId.makeUnsafe("project-2");
apps/web/src/store.ts+1
......@@ -299,6 +299,7 @@ export function syncServerReadModel(state: AppState, readModel: OrchestrationRea
299299 })),
300300 error: thread.session?.lastError ?? null,
301301 createdAt: thread.createdAt,
302 archivedAt: thread.archivedAt,
302303 updatedAt: thread.updatedAt,
303304 latestTurn: thread.latestTurn,
304305 lastVisitedAt: existing?.lastVisitedAt ?? thread.updatedAt,
apps/web/src/terminalStateStore.test.ts+1-3
......@@ -7,9 +7,7 @@ const THREAD_ID = ThreadId.makeUnsafe("thread-1");
77
88describe("terminalStateStore actions", () => {
99 beforeEach(() => {
10 if (typeof localStorage !== "undefined") {
11 localStorage.clear();
12 }
10 useTerminalStateStore.persist.clearStorage();
1311 useTerminalStateStore.setState({ terminalStateByThreadId: {} });
1412 });
1513
apps/web/src/terminalStateStore.ts+6-1
......@@ -8,6 +8,7 @@
88import type { ThreadId } from "@t3tools/contracts";
99import { create } from "zustand";
1010import { createJSONStorage, persist } from "zustand/middleware";
11import { resolveStorage } from "./lib/storage";
1112import {
1213 DEFAULT_THREAD_TERMINAL_HEIGHT,
1314 DEFAULT_THREAD_TERMINAL_ID,
......@@ -27,6 +28,10 @@ interface ThreadTerminalState {
2728
2829const TERMINAL_STATE_STORAGE_KEY = "t3code:terminal-state:v1";
2930
31function createTerminalStateStorage() {
32 return resolveStorage(typeof window !== "undefined" ? window.localStorage : undefined);
33}
34
3035function normalizeTerminalIds(terminalIds: string[]): string[] {
3136 const ids = [...new Set(terminalIds.map((id) => id.trim()).filter((id) => id.length > 0))];
3237 return ids.length > 0 ? ids : [DEFAULT_THREAD_TERMINAL_ID];
......@@ -542,7 +547,7 @@ export const useTerminalStateStore = create<TerminalStateStoreState>()(
542547 {
543548 name: TERMINAL_STATE_STORAGE_KEY,
544549 version: 1,
545 storage: createJSONStorage(() => localStorage),
550 storage: createJSONStorage(createTerminalStateStorage),
546551 partialize: (state) => ({
547552 terminalStateByThreadId: state.terminalStateByThreadId,
548553 }),
apps/web/src/timestampFormat.ts+5
......@@ -66,3 +66,8 @@ export function formatRelativeTime(isoDate: string): { value: string; suffix: st
6666 const days = Math.floor(hours / 24);
6767 return { value: `${days}d`, suffix: "ago" };
6868}
69
70export function formatRelativeTimeLabel(isoDate: string) {
71 const relative = formatRelativeTime(isoDate);
72 return relative.suffix ? `${relative.value} ${relative.suffix}` : relative.value;
73}
apps/web/src/types.ts+1
......@@ -101,6 +101,7 @@ export interface Thread {
101101 proposedPlans: ProposedPlan[];
102102 error: string | null;
103103 createdAt: string;
104 archivedAt: string | null;
104105 updatedAt?: string | undefined;
105106 latestTurn: OrchestrationLatestTurn | null;
106107 lastVisitedAt?: string | undefined;
apps/web/src/worktreeCleanup.test.ts+1
......@@ -23,6 +23,7 @@ function makeThread(overrides: Partial<Thread> = {}): Thread {
2323 proposedPlans: [],
2424 error: null,
2525 createdAt: "2026-02-13T00:00:00.000Z",
26 archivedAt: null,
2627 latestTurn: null,
2728 branch: null,
2829 worktreePath: null,
packages/contracts/src/ipc.ts+1
......@@ -57,6 +57,7 @@ export interface ContextMenuItem<T extends string = string> {
5757 id: T;
5858 label: string;
5959 destructive?: boolean;
60 disabled?: boolean;
6061}
6162
6263export type DesktopUpdateStatus =
packages/contracts/src/orchestration.test.ts+64
......@@ -5,6 +5,8 @@ import { Effect, Schema } from "effect";
55import {
66 DEFAULT_PROVIDER_INTERACTION_MODE,
77 DEFAULT_RUNTIME_MODE,
8 OrchestrationCommand,
9 OrchestrationEvent,
810 OrchestrationGetTurnDiffInput,
911 OrchestrationLatestTurn,
1012 ProjectCreatedPayload,
......@@ -32,6 +34,8 @@ const decodeOrchestrationLatestTurn = Schema.decodeUnknownEffect(OrchestrationLa
3234const decodeOrchestrationProposedPlan = Schema.decodeUnknownEffect(OrchestrationProposedPlan);
3335const decodeOrchestrationSession = Schema.decodeUnknownEffect(OrchestrationSession);
3436const decodeThreadCreatedPayload = Schema.decodeUnknownEffect(ThreadCreatedPayload);
37const decodeOrchestrationCommand = Schema.decodeUnknownEffect(OrchestrationCommand);
38const decodeOrchestrationEvent = Schema.decodeUnknownEffect(OrchestrationEvent);
3539const decodeThreadMetaUpdatedPayload = Schema.decodeUnknownEffect(ThreadMetaUpdatedPayload);
3640
3741it.effect("parses turn diff input when fromTurnCount <= toTurnCount", () =>
......@@ -227,6 +231,66 @@ it.effect("decodes thread.meta-updated payloads with explicit provider", () =>
227231 }),
228232);
229233
234it.effect("decodes thread archive and unarchive commands", () =>
235 Effect.gen(function* () {
236 const archive = yield* decodeOrchestrationCommand({
237 type: "thread.archive",
238 commandId: "cmd-archive-1",
239 threadId: "thread-1",
240 });
241 const unarchive = yield* decodeOrchestrationCommand({
242 type: "thread.unarchive",
243 commandId: "cmd-unarchive-1",
244 threadId: "thread-1",
245 });
246
247 assert.strictEqual(archive.type, "thread.archive");
248 assert.strictEqual(unarchive.type, "thread.unarchive");
249 }),
250);
251
252it.effect("decodes thread archived and unarchived events", () =>
253 Effect.gen(function* () {
254 const archived = yield* decodeOrchestrationEvent({
255 sequence: 1,
256 eventId: "event-archive-1",
257 aggregateKind: "thread",
258 aggregateId: "thread-1",
259 type: "thread.archived",
260 occurredAt: "2026-01-01T00:00:00.000Z",
261 commandId: "cmd-archive-1",
262 causationEventId: null,
263 correlationId: "cmd-archive-1",
264 metadata: {},
265 payload: {
266 threadId: "thread-1",
267 archivedAt: "2026-01-01T00:00:00.000Z",
268 updatedAt: "2026-01-01T00:00:00.000Z",
269 },
270 });
271 const unarchived = yield* decodeOrchestrationEvent({
272 sequence: 2,
273 eventId: "event-unarchive-1",
274 aggregateKind: "thread",
275 aggregateId: "thread-1",
276 type: "thread.unarchived",
277 occurredAt: "2026-01-02T00:00:00.000Z",
278 commandId: "cmd-unarchive-1",
279 causationEventId: null,
280 correlationId: "cmd-unarchive-1",
281 metadata: {},
282 payload: {
283 threadId: "thread-1",
284 updatedAt: "2026-01-02T00:00:00.000Z",
285 },
286 });
287
288 assert.strictEqual(archived.type, "thread.archived");
289 assert.strictEqual(archived.payload.archivedAt, "2026-01-01T00:00:00.000Z");
290 assert.strictEqual(unarchived.type, "thread.unarchived");
291 }),
292);
293
230294it.effect("accepts provider-scoped model options in thread.turn.start", () =>
231295 Effect.gen(function* () {
232296 const parsed = yield* decodeThreadTurnStartCommand({
packages/contracts/src/orchestration.ts+40
......@@ -283,6 +283,7 @@ export const OrchestrationThread = Schema.Struct({
283283 latestTurn: Schema.NullOr(OrchestrationLatestTurn),
284284 createdAt: IsoDateTime,
285285 updatedAt: IsoDateTime,
286 archivedAt: Schema.NullOr(IsoDateTime).pipe(Schema.withDecodingDefault(() => null)),
286287 deletedAt: Schema.NullOr(IsoDateTime),
287288 messages: Schema.Array(OrchestrationMessage),
288289 proposedPlans: Schema.Array(OrchestrationProposedPlan).pipe(Schema.withDecodingDefault(() => [])),
......@@ -348,6 +349,18 @@ const ThreadDeleteCommand = Schema.Struct({
348349 threadId: ThreadId,
349350});
350351
352const ThreadArchiveCommand = Schema.Struct({
353 type: Schema.Literal("thread.archive"),
354 commandId: CommandId,
355 threadId: ThreadId,
356});
357
358const ThreadUnarchiveCommand = Schema.Struct({
359 type: Schema.Literal("thread.unarchive"),
360 commandId: CommandId,
361 threadId: ThreadId,
362});
363
351364const ThreadMetaUpdateCommand = Schema.Struct({
352365 type: Schema.Literal("thread.meta.update"),
353366 commandId: CommandId,
......@@ -457,6 +470,8 @@ const DispatchableClientOrchestrationCommand = Schema.Union([
457470 ProjectDeleteCommand,
458471 ThreadCreateCommand,
459472 ThreadDeleteCommand,
473 ThreadArchiveCommand,
474 ThreadUnarchiveCommand,
460475 ThreadMetaUpdateCommand,
461476 ThreadRuntimeModeSetCommand,
462477 ThreadInteractionModeSetCommand,
......@@ -476,6 +491,8 @@ export const ClientOrchestrationCommand = Schema.Union([
476491 ProjectDeleteCommand,
477492 ThreadCreateCommand,
478493 ThreadDeleteCommand,
494 ThreadArchiveCommand,
495 ThreadUnarchiveCommand,
479496 ThreadMetaUpdateCommand,
480497 ThreadRuntimeModeSetCommand,
481498 ThreadInteractionModeSetCommand,
......@@ -576,6 +593,8 @@ export const OrchestrationEventType = Schema.Literals([
576593 "project.deleted",
577594 "thread.created",
578595 "thread.deleted",
596 "thread.archived",
597 "thread.unarchived",
579598 "thread.meta-updated",
580599 "thread.runtime-mode-set",
581600 "thread.interaction-mode-set",
......@@ -642,6 +661,17 @@ export const ThreadDeletedPayload = Schema.Struct({
642661 deletedAt: IsoDateTime,
643662});
644663
664export const ThreadArchivedPayload = Schema.Struct({
665 threadId: ThreadId,
666 archivedAt: IsoDateTime,
667 updatedAt: IsoDateTime,
668});
669
670export const ThreadUnarchivedPayload = Schema.Struct({
671 threadId: ThreadId,
672 updatedAt: IsoDateTime,
673});
674
645675export const ThreadMetaUpdatedPayload = Schema.Struct({
646676 threadId: ThreadId,
647677 title: Schema.optional(TrimmedNonEmptyString),
......@@ -799,6 +829,16 @@ export const OrchestrationEvent = Schema.Union([
799829 type: Schema.Literal("thread.deleted"),
800830 payload: ThreadDeletedPayload,
801831 }),
832 Schema.Struct({
833 ...EventBaseFields,
834 type: Schema.Literal("thread.archived"),
835 payload: ThreadArchivedPayload,
836 }),
837 Schema.Struct({
838 ...EventBaseFields,
839 type: Schema.Literal("thread.unarchived"),
840 payload: ThreadUnarchivedPayload,
841 }),
802842 Schema.Struct({
803843 ...EventBaseFields,
804844 type: Schema.Literal("thread.meta-updated"),
packages/contracts/src/settings.test.ts created+8
......@@ -0,0 +1,8 @@
1import { describe, expect, it } from "vitest";
2import { DEFAULT_CLIENT_SETTINGS } from "./settings";
3
4describe("DEFAULT_CLIENT_SETTINGS", () => {
5 it("includes archive confirmation with a false default", () => {
6 expect(DEFAULT_CLIENT_SETTINGS.confirmThreadArchive).toBe(false);
7 });
8});
packages/contracts/src/settings.ts+1
......@@ -24,6 +24,7 @@ export type SidebarThreadSortOrder = typeof SidebarThreadSortOrder.Type;
2424export const DEFAULT_SIDEBAR_THREAD_SORT_ORDER: SidebarThreadSortOrder = "updated_at";
2525
2626export const ClientSettingsSchema = Schema.Struct({
27 confirmThreadArchive: Schema.Boolean.pipe(Schema.withDecodingDefault(() => false)),
2728 confirmThreadDelete: Schema.Boolean.pipe(Schema.withDecodingDefault(() => true)),
2829 diffWordWrap: Schema.Boolean.pipe(Schema.withDecodingDefault(() => false)),
2930 sidebarProjectSortOrder: SidebarProjectSortOrder.pipe(