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 {...@@ -1141,6 +1141,7 @@ function registerIpcHandlers(): void {
1141 id: item.id,1141 id: item.id,
1142 label: item.label,1142 label: item.label,
1143 destructive: item.destructive === true,1143 destructive: item.destructive === true,
1144 disabled: item.disabled === true,
1144 }));1145 }));
1145 if (normalizedItems.length === 0) {1146 if (normalizedItems.length === 0) {
1146 return null;1147 return null;
...@@ -1171,6 +1172,7 @@ function registerIpcHandlers(): void {...@@ -1171,6 +1172,7 @@ function registerIpcHandlers(): void {
1171 }1172 }
1172 const itemOption: MenuItemConstructorOptions = {1173 const itemOption: MenuItemConstructorOptions = {
1173 label: item.label,1174 label: item.label,
1175 enabled: !item.disabled,
1174 click: () => resolve(item.id),1176 click: () => resolve(item.id),
1175 };1177 };
1176 if (item.destructive) {1178 if (item.destructive) {
apps/server/src/checkpointing/Layers/CheckpointDiffQuery.test.ts+1
...@@ -61,6 +61,7 @@ function makeSnapshot(input: {...@@ -61,6 +61,7 @@ function makeSnapshot(input: {
61 },61 },
62 createdAt: "2026-01-01T00:00:00.000Z",62 createdAt: "2026-01-01T00:00:00.000Z",
63 updatedAt: "2026-01-01T00:00:00.000Z",63 updatedAt: "2026-01-01T00:00:00.000Z",
64 archivedAt: null,
64 deletedAt: null,65 deletedAt: null,
65 messages: [],66 messages: [],
66 activities: [],67 activities: [],
apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts+67
...@@ -120,6 +120,73 @@ describe("OrchestrationEngine", () => {...@@ -120,6 +120,73 @@ describe("OrchestrationEngine", () => {
120 await system.dispose();120 await system.dispose();
121 });121 });
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
123 it("replays append-only events from sequence", async () => {190 it("replays append-only events from sequence", async () => {
124 const system = await createOrchestrationSystem();191 const system = await createOrchestrationSystem();
125 const { engine } = system;192 const { engine } = system;
apps/server/src/orchestration/Layers/ProjectionPipeline.ts+31
...@@ -428,10 +428,41 @@ const makeOrchestrationProjectionPipeline = Effect.gen(function* () {...@@ -428,10 +428,41 @@ const makeOrchestrationProjectionPipeline = Effect.gen(function* () {
428 latestTurnId: null,428 latestTurnId: null,
429 createdAt: event.payload.createdAt,429 createdAt: event.payload.createdAt,
430 updatedAt: event.payload.updatedAt,430 updatedAt: event.payload.updatedAt,
431 archivedAt: null,
431 deletedAt: null,432 deletedAt: null,
432 });433 });
433 return;434 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
435 case "thread.meta-updated": {466 case "thread.meta-updated": {
436 const existingRow = yield* projectionThreadRepository.getById({467 const existingRow = yield* projectionThreadRepository.getById({
437 threadId: event.payload.threadId,468 threadId: event.payload.threadId,
apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts+1
...@@ -279,6 +279,7 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => {...@@ -279,6 +279,7 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => {
279 },279 },
280 createdAt: "2026-02-24T00:00:02.000Z",280 createdAt: "2026-02-24T00:00:02.000Z",
281 updatedAt: "2026-02-24T00:00:03.000Z",281 updatedAt: "2026-02-24T00:00:03.000Z",
282 archivedAt: null,
282 deletedAt: null,283 deletedAt: null,
283 messages: [284 messages: [
284 {285 {
apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts+2
...@@ -174,6 +174,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {...@@ -174,6 +174,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {
174 latest_turn_id AS "latestTurnId",174 latest_turn_id AS "latestTurnId",
175 created_at AS "createdAt",175 created_at AS "createdAt",
176 updated_at AS "updatedAt",176 updated_at AS "updatedAt",
177 archived_at AS "archivedAt",
177 deleted_at AS "deletedAt"178 deleted_at AS "deletedAt"
178 FROM projection_threads179 FROM projection_threads
179 ORDER BY created_at ASC, thread_id ASC180 ORDER BY created_at ASC, thread_id ASC
...@@ -560,6 +561,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {...@@ -560,6 +561,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {
560 latestTurn: latestTurnByThread.get(row.threadId) ?? null,561 latestTurn: latestTurnByThread.get(row.threadId) ?? null,
561 createdAt: row.createdAt,562 createdAt: row.createdAt,
562 updatedAt: row.updatedAt,563 updatedAt: row.updatedAt,
564 archivedAt: row.archivedAt,
563 deletedAt: row.deletedAt,565 deletedAt: row.deletedAt,
564 messages: messagesByThread.get(row.threadId) ?? [],566 messages: messagesByThread.get(row.threadId) ?? [],
565 proposedPlans: proposedPlansByThread.get(row.threadId) ?? [],567 proposedPlans: proposedPlansByThread.get(row.threadId) ?? [],
apps/server/src/orchestration/Schemas.ts+4
...@@ -3,10 +3,12 @@ import {...@@ -3,10 +3,12 @@ import {
3 ProjectMetaUpdatedPayload as ContractsProjectMetaUpdatedPayloadSchema,3 ProjectMetaUpdatedPayload as ContractsProjectMetaUpdatedPayloadSchema,
4 ProjectDeletedPayload as ContractsProjectDeletedPayloadSchema,4 ProjectDeletedPayload as ContractsProjectDeletedPayloadSchema,
5 ThreadCreatedPayload as ContractsThreadCreatedPayloadSchema,5 ThreadCreatedPayload as ContractsThreadCreatedPayloadSchema,
6 ThreadArchivedPayload as ContractsThreadArchivedPayloadSchema,
6 ThreadMetaUpdatedPayload as ContractsThreadMetaUpdatedPayloadSchema,7 ThreadMetaUpdatedPayload as ContractsThreadMetaUpdatedPayloadSchema,
7 ThreadRuntimeModeSetPayload as ContractsThreadRuntimeModeSetPayloadSchema,8 ThreadRuntimeModeSetPayload as ContractsThreadRuntimeModeSetPayloadSchema,
8 ThreadInteractionModeSetPayload as ContractsThreadInteractionModeSetPayloadSchema,9 ThreadInteractionModeSetPayload as ContractsThreadInteractionModeSetPayloadSchema,
9 ThreadDeletedPayload as ContractsThreadDeletedPayloadSchema,10 ThreadDeletedPayload as ContractsThreadDeletedPayloadSchema,
11 ThreadUnarchivedPayload as ContractsThreadUnarchivedPayloadSchema,
10 ThreadMessageSentPayload as ContractsThreadMessageSentPayloadSchema,12 ThreadMessageSentPayload as ContractsThreadMessageSentPayloadSchema,
11 ThreadProposedPlanUpsertedPayload as ContractsThreadProposedPlanUpsertedPayloadSchema,13 ThreadProposedPlanUpsertedPayload as ContractsThreadProposedPlanUpsertedPayloadSchema,
12 ThreadSessionSetPayload as ContractsThreadSessionSetPayloadSchema,14 ThreadSessionSetPayload as ContractsThreadSessionSetPayloadSchema,
...@@ -26,10 +28,12 @@ export const ProjectMetaUpdatedPayload = ContractsProjectMetaUpdatedPayloadSchem...@@ -26,10 +28,12 @@ export const ProjectMetaUpdatedPayload = ContractsProjectMetaUpdatedPayloadSchem
26export const ProjectDeletedPayload = ContractsProjectDeletedPayloadSchema;28export const ProjectDeletedPayload = ContractsProjectDeletedPayloadSchema;
2729
28export const ThreadCreatedPayload = ContractsThreadCreatedPayloadSchema;30export const ThreadCreatedPayload = ContractsThreadCreatedPayloadSchema;
31export const ThreadArchivedPayload = ContractsThreadArchivedPayloadSchema;
29export const ThreadMetaUpdatedPayload = ContractsThreadMetaUpdatedPayloadSchema;32export const ThreadMetaUpdatedPayload = ContractsThreadMetaUpdatedPayloadSchema;
30export const ThreadRuntimeModeSetPayload = ContractsThreadRuntimeModeSetPayloadSchema;33export const ThreadRuntimeModeSetPayload = ContractsThreadRuntimeModeSetPayloadSchema;
31export const ThreadInteractionModeSetPayload = ContractsThreadInteractionModeSetPayloadSchema;34export const ThreadInteractionModeSetPayload = ContractsThreadInteractionModeSetPayloadSchema;
32export const ThreadDeletedPayload = ContractsThreadDeletedPayloadSchema;35export const ThreadDeletedPayload = ContractsThreadDeletedPayloadSchema;
36export const ThreadUnarchivedPayload = ContractsThreadUnarchivedPayloadSchema;
3337
34export const MessageSentPayloadSchema = ContractsThreadMessageSentPayloadSchema;38export const MessageSentPayloadSchema = ContractsThreadMessageSentPayloadSchema;
35export const ThreadProposedPlanUpsertedPayload = ContractsThreadProposedPlanUpsertedPayloadSchema;39export const ThreadProposedPlanUpsertedPayload = ContractsThreadProposedPlanUpsertedPayloadSchema;
apps/server/src/orchestration/commandInvariants.test.ts+2
...@@ -66,6 +66,7 @@ const readModel: OrchestrationReadModel = {...@@ -66,6 +66,7 @@ const readModel: OrchestrationReadModel = {
66 worktreePath: null,66 worktreePath: null,
67 createdAt: now,67 createdAt: now,
68 updatedAt: now,68 updatedAt: now,
69 archivedAt: null,
69 latestTurn: null,70 latestTurn: null,
70 messages: [],71 messages: [],
71 session: null,72 session: null,
...@@ -88,6 +89,7 @@ const readModel: OrchestrationReadModel = {...@@ -88,6 +89,7 @@ const readModel: OrchestrationReadModel = {
88 worktreePath: null,89 worktreePath: null,
89 createdAt: now,90 createdAt: now,
90 updatedAt: now,91 updatedAt: now,
92 archivedAt: null,
91 latestTurn: null,93 latestTurn: null,
92 messages: [],94 messages: [],
93 session: null,95 session: null,
apps/server/src/orchestration/commandInvariants.ts+38
...@@ -88,6 +88,44 @@ export function requireThread(input: {...@@ -88,6 +88,44 @@ export function requireThread(input: {
88 );88 );
89}89}
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
91export function requireThreadAbsent(input: {129export function requireThreadAbsent(input: {
92 readonly readModel: OrchestrationReadModel;130 readonly readModel: OrchestrationReadModel;
93 readonly command: OrchestrationCommand;131 readonly command: OrchestrationCommand;
apps/server/src/orchestration/decider.ts+47
...@@ -10,7 +10,9 @@ import {...@@ -10,7 +10,9 @@ import {
10 requireProject,10 requireProject,
11 requireProjectAbsent,11 requireProjectAbsent,
12 requireThread,12 requireThread,
13 requireThreadArchived,
13 requireThreadAbsent,14 requireThreadAbsent,
15 requireThreadNotArchived,
14} from "./commandInvariants.ts";16} from "./commandInvariants.ts";
1517
16const nowIso = () => new Date().toISOString();18const nowIso = () => new Date().toISOString();
...@@ -190,6 +192,51 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand"...@@ -190,6 +192,51 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand"
190 };192 };
191 }193 }
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
193 case "thread.meta.update": {240 case "thread.meta.update": {
194 yield* requireThread({241 yield* requireThread({
195 readModel,242 readModel,
apps/server/src/orchestration/projector.test.ts+73
...@@ -87,6 +87,7 @@ describe("orchestration projector", () => {...@@ -87,6 +87,7 @@ describe("orchestration projector", () => {
87 latestTurn: null,87 latestTurn: null,
88 createdAt: now,88 createdAt: now,
89 updatedAt: now,89 updatedAt: now,
90 archivedAt: null,
90 deletedAt: null,91 deletedAt: null,
91 messages: [],92 messages: [],
92 proposedPlans: [],93 proposedPlans: [],
...@@ -131,6 +132,78 @@ describe("orchestration projector", () => {...@@ -131,6 +132,78 @@ describe("orchestration projector", () => {
131 ).rejects.toBeDefined();132 ).rejects.toBeDefined();
132 });133 });
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
134 it("keeps projector forward-compatible for unhandled event types", async () => {207 it("keeps projector forward-compatible for unhandled event types", async () => {
135 const now = new Date().toISOString();208 const now = new Date().toISOString();
136 const model = createEmptyReadModel(now);209 const model = createEmptyReadModel(now);
apps/server/src/orchestration/projector.ts+25
...@@ -14,12 +14,14 @@ import {...@@ -14,12 +14,14 @@ import {
14 ProjectDeletedPayload,14 ProjectDeletedPayload,
15 ProjectMetaUpdatedPayload,15 ProjectMetaUpdatedPayload,
16 ThreadActivityAppendedPayload,16 ThreadActivityAppendedPayload,
17 ThreadArchivedPayload,
17 ThreadCreatedPayload,18 ThreadCreatedPayload,
18 ThreadDeletedPayload,19 ThreadDeletedPayload,
19 ThreadInteractionModeSetPayload,20 ThreadInteractionModeSetPayload,
20 ThreadMetaUpdatedPayload,21 ThreadMetaUpdatedPayload,
21 ThreadProposedPlanUpsertedPayload,22 ThreadProposedPlanUpsertedPayload,
22 ThreadRuntimeModeSetPayload,23 ThreadRuntimeModeSetPayload,
24 ThreadUnarchivedPayload,
23 ThreadRevertedPayload,25 ThreadRevertedPayload,
24 ThreadSessionSetPayload,26 ThreadSessionSetPayload,
25 ThreadTurnDiffCompletedPayload,27 ThreadTurnDiffCompletedPayload,
...@@ -260,6 +262,7 @@ export function projectEvent(...@@ -260,6 +262,7 @@ export function projectEvent(
260 latestTurn: null,262 latestTurn: null,
261 createdAt: payload.createdAt,263 createdAt: payload.createdAt,
262 updatedAt: payload.updatedAt,264 updatedAt: payload.updatedAt,
265 archivedAt: null,
263 deletedAt: null,266 deletedAt: null,
264 messages: [],267 messages: [],
265 activities: [],268 activities: [],
...@@ -289,6 +292,28 @@ export function projectEvent(...@@ -289,6 +292,28 @@ export function projectEvent(
289 })),292 })),
290 );293 );
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
292 case "thread.meta-updated":317 case "thread.meta-updated":
293 return decodeForEvent(ThreadMetaUpdatedPayload, event.payload, event.type, "payload").pipe(318 return decodeForEvent(ThreadMetaUpdatedPayload, event.payload, event.type, "payload").pipe(
294 Effect.map((payload) => ({319 Effect.map((payload) => ({
apps/server/src/persistence/Layers/ProjectionRepositories.test.ts+1
...@@ -87,6 +87,7 @@ projectionRepositoriesLayer("Projection repositories", (it) => {...@@ -87,6 +87,7 @@ projectionRepositoriesLayer("Projection repositories", (it) => {
87 latestTurnId: null,87 latestTurnId: null,
88 createdAt: "2026-03-24T00:00:00.000Z",88 createdAt: "2026-03-24T00:00:00.000Z",
89 updatedAt: "2026-03-24T00:00:00.000Z",89 updatedAt: "2026-03-24T00:00:00.000Z",
90 archivedAt: null,
90 deletedAt: null,91 deletedAt: null,
91 });92 });
9293
apps/server/src/persistence/Layers/ProjectionThreads.ts+5
...@@ -39,6 +39,7 @@ const makeProjectionThreadRepository = Effect.gen(function* () {...@@ -39,6 +39,7 @@ const makeProjectionThreadRepository = Effect.gen(function* () {
39 latest_turn_id,39 latest_turn_id,
40 created_at,40 created_at,
41 updated_at,41 updated_at,
42 archived_at,
42 deleted_at43 deleted_at
43 )44 )
44 VALUES (45 VALUES (
...@@ -53,6 +54,7 @@ const makeProjectionThreadRepository = Effect.gen(function* () {...@@ -53,6 +54,7 @@ const makeProjectionThreadRepository = Effect.gen(function* () {
53 ${row.latestTurnId},54 ${row.latestTurnId},
54 ${row.createdAt},55 ${row.createdAt},
55 ${row.updatedAt},56 ${row.updatedAt},
57 ${row.archivedAt},
56 ${row.deletedAt}58 ${row.deletedAt}
57 )59 )
58 ON CONFLICT (thread_id)60 ON CONFLICT (thread_id)
...@@ -67,6 +69,7 @@ const makeProjectionThreadRepository = Effect.gen(function* () {...@@ -67,6 +69,7 @@ const makeProjectionThreadRepository = Effect.gen(function* () {
67 latest_turn_id = excluded.latest_turn_id,69 latest_turn_id = excluded.latest_turn_id,
68 created_at = excluded.created_at,70 created_at = excluded.created_at,
69 updated_at = excluded.updated_at,71 updated_at = excluded.updated_at,
72 archived_at = excluded.archived_at,
70 deleted_at = excluded.deleted_at73 deleted_at = excluded.deleted_at
71 `,74 `,
72 });75 });
...@@ -88,6 +91,7 @@ const makeProjectionThreadRepository = Effect.gen(function* () {...@@ -88,6 +91,7 @@ const makeProjectionThreadRepository = Effect.gen(function* () {
88 latest_turn_id AS "latestTurnId",91 latest_turn_id AS "latestTurnId",
89 created_at AS "createdAt",92 created_at AS "createdAt",
90 updated_at AS "updatedAt",93 updated_at AS "updatedAt",
94 archived_at AS "archivedAt",
91 deleted_at AS "deletedAt"95 deleted_at AS "deletedAt"
92 FROM projection_threads96 FROM projection_threads
93 WHERE thread_id = ${threadId}97 WHERE thread_id = ${threadId}
...@@ -111,6 +115,7 @@ const makeProjectionThreadRepository = Effect.gen(function* () {...@@ -111,6 +115,7 @@ const makeProjectionThreadRepository = Effect.gen(function* () {
111 latest_turn_id AS "latestTurnId",115 latest_turn_id AS "latestTurnId",
112 created_at AS "createdAt",116 created_at AS "createdAt",
113 updated_at AS "updatedAt",117 updated_at AS "updatedAt",
118 archived_at AS "archivedAt",
114 deleted_at AS "deletedAt"119 deleted_at AS "deletedAt"
115 FROM projection_threads120 FROM projection_threads
116 WHERE project_id = ${projectId}121 WHERE project_id = ${projectId}
apps/server/src/persistence/Migrations.ts+4
...@@ -29,6 +29,8 @@ import Migration0013 from "./Migrations/013_ProjectionThreadProposedPlans.ts";...@@ -29,6 +29,8 @@ import Migration0013 from "./Migrations/013_ProjectionThreadProposedPlans.ts";
29import Migration0014 from "./Migrations/014_ProjectionThreadProposedPlanImplementation.ts";29import Migration0014 from "./Migrations/014_ProjectionThreadProposedPlanImplementation.ts";
30import Migration0015 from "./Migrations/015_ProjectionTurnsSourceProposedPlan.ts";30import Migration0015 from "./Migrations/015_ProjectionTurnsSourceProposedPlan.ts";
31import Migration0016 from "./Migrations/016_CanonicalizeModelSelections.ts";31import Migration0016 from "./Migrations/016_CanonicalizeModelSelections.ts";
32import Migration0017 from "./Migrations/017_ProjectionThreadsArchivedAt.ts";
33import Migration0018 from "./Migrations/018_ProjectionThreadsArchivedAtIndex.ts";
3234
33/**35/**
34 * Migration loader with all migrations defined inline.36 * Migration loader with all migrations defined inline.
...@@ -57,6 +59,8 @@ export const migrationEntries = [...@@ -57,6 +59,8 @@ export const migrationEntries = [
57 [14, "ProjectionThreadProposedPlanImplementation", Migration0014],59 [14, "ProjectionThreadProposedPlanImplementation", Migration0014],
58 [15, "ProjectionTurnsSourceProposedPlan", Migration0015],60 [15, "ProjectionTurnsSourceProposedPlan", Migration0015],
59 [16, "CanonicalizeModelSelections", Migration0016],61 [16, "CanonicalizeModelSelections", Migration0016],
62 [17, "ProjectionThreadsArchivedAt", Migration0017],
63 [18, "ProjectionThreadsArchivedAtIndex", Migration0018],
60] as const;64] as const;
6165
62export const makeMigrationLoader = (throughId?: number) =>66export const makeMigrationLoader = (throughId?: number) =>
apps/server/src/persistence/Migrations/016_CanonicalizeModelSelections.test.ts+27-3
...@@ -128,6 +128,20 @@ layer("016_CanonicalizeModelSelections", (it) => {...@@ -128,6 +128,20 @@ layer("016_CanonicalizeModelSelections", (it) => {
128 '{"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"}',128 '{"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"}',
129 '{}'129 '{}'
130 ),130 ),
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 ),
131 (145 (
132 'event-thread-created',146 'event-thread-created',
133 'thread',147 'thread',
...@@ -284,6 +298,16 @@ layer("016_CanonicalizeModelSelections", (it) => {...@@ -284,6 +298,16 @@ layer("016_CanonicalizeModelSelections", (it) => {
284 });298 });
285299
286 assert.deepStrictEqual(JSON.parse(eventRows[2]!.payloadJson), {300 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), {
287 threadId: "thread-1",311 threadId: "thread-1",
288 projectId: "project-1",312 projectId: "project-1",
289 title: "Thread",313 title: "Thread",
...@@ -303,7 +327,7 @@ layer("016_CanonicalizeModelSelections", (it) => {...@@ -303,7 +327,7 @@ layer("016_CanonicalizeModelSelections", (it) => {
303 updatedAt: "2026-01-01T00:00:00.000Z",327 updatedAt: "2026-01-01T00:00:00.000Z",
304 });328 });
305329
306 assert.deepStrictEqual(JSON.parse(eventRows[3]!.payloadJson), {330 assert.deepStrictEqual(JSON.parse(eventRows[4]!.payloadJson), {
307 threadId: "thread-2",331 threadId: "thread-2",
308 projectId: "project-1",332 projectId: "project-1",
309 title: "Fallback Thread",333 title: "Fallback Thread",
...@@ -322,7 +346,7 @@ layer("016_CanonicalizeModelSelections", (it) => {...@@ -322,7 +346,7 @@ layer("016_CanonicalizeModelSelections", (it) => {
322 updatedAt: "2026-01-01T00:00:00.000Z",346 updatedAt: "2026-01-01T00:00:00.000Z",
323 });347 });
324348
325 assert.deepStrictEqual(JSON.parse(eventRows[4]!.payloadJson), {349 assert.deepStrictEqual(JSON.parse(eventRows[5]!.payloadJson), {
326 threadId: "thread-1",350 threadId: "thread-1",
327 turnId: "turn-1",351 turnId: "turn-1",
328 input: "hi",352 input: "hi",
...@@ -336,7 +360,7 @@ layer("016_CanonicalizeModelSelections", (it) => {...@@ -336,7 +360,7 @@ layer("016_CanonicalizeModelSelections", (it) => {
336 deliveryMode: "buffered",360 deliveryMode: "buffered",
337 });361 });
338362
339 assert.deepStrictEqual(JSON.parse(eventRows[5]!.payloadJson), {363 assert.deepStrictEqual(JSON.parse(eventRows[6]!.payloadJson), {
340 threadId: "thread-3",364 threadId: "thread-3",
341 projectId: "project-1",365 projectId: "project-1",
342 title: "Ancient Thread",366 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({...@@ -32,6 +32,7 @@ export const ProjectionThread = Schema.Struct({
32 latestTurnId: Schema.NullOr(TurnId),32 latestTurnId: Schema.NullOr(TurnId),
33 createdAt: IsoDateTime,33 createdAt: IsoDateTime,
34 updatedAt: IsoDateTime,34 updatedAt: IsoDateTime,
35 archivedAt: Schema.NullOr(IsoDateTime),
35 deletedAt: Schema.NullOr(IsoDateTime),36 deletedAt: Schema.NullOr(IsoDateTime),
36});37});
37export type ProjectionThread = typeof ProjectionThread.Type;38export type ProjectionThread = typeof ProjectionThread.Type;
apps/server/src/terminal/Layers/Manager.test.ts+3-3
...@@ -672,9 +672,9 @@ describe("TerminalManager", () => {...@@ -672,9 +672,9 @@ describe("TerminalManager", () => {
672 ).toBe(true);672 ).toBe(true);
673 } else {673 } else {
674 expect(674 expect(
675 ptyAdapter.spawnInputs.some((input) =>675 ptyAdapter.spawnInputs
676 ["/bin/zsh", "/bin/bash", "/bin/sh", "zsh", "bash", "sh"].includes(input.shell),676 .slice(1)
677 ),677 .some((input) => input.shell !== "/definitely/missing-shell"),
678 ).toBe(true);678 ).toBe(true);
679 }679 }
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: {...@@ -258,6 +258,7 @@ function createSnapshotForTargetUser(options: {
258 latestTurn: null,258 latestTurn: null,
259 createdAt: NOW_ISO,259 createdAt: NOW_ISO,
260 updatedAt: NOW_ISO,260 updatedAt: NOW_ISO,
261 archivedAt: null,
261 deletedAt: null,262 deletedAt: null,
262 messages,263 messages,
263 activities: [],264 activities: [],
...@@ -315,6 +316,7 @@ function addThreadToSnapshot(...@@ -315,6 +316,7 @@ function addThreadToSnapshot(
315 latestTurn: null,316 latestTurn: null,
316 createdAt: NOW_ISO,317 createdAt: NOW_ISO,
317 updatedAt: NOW_ISO,318 updatedAt: NOW_ISO,
319 archivedAt: null,
318 deletedAt: null,320 deletedAt: null,
319 messages: [],321 messages: [],
320 activities: [],322 activities: [],
apps/web/src/components/ChatView.logic.ts+1
...@@ -32,6 +32,7 @@ export function buildLocalDraftThread(...@@ -32,6 +32,7 @@ export function buildLocalDraftThread(
32 messages: [],32 messages: [],
33 error,33 error,
34 createdAt: draftThread.createdAt,34 createdAt: draftThread.createdAt,
35 archivedAt: null,
35 latestTurn: null,36 latestTurn: null,
36 lastVisitedAt: draftThread.createdAt,37 lastVisitedAt: draftThread.createdAt,
37 branch: draftThread.branch,38 branch: draftThread.branch,
apps/web/src/components/KeybindingsToast.browser.tsx+1
...@@ -102,6 +102,7 @@ function createMinimalSnapshot(): OrchestrationReadModel {...@@ -102,6 +102,7 @@ function createMinimalSnapshot(): OrchestrationReadModel {
102 latestTurn: null,102 latestTurn: null,
103 createdAt: NOW_ISO,103 createdAt: NOW_ISO,
104 updatedAt: NOW_ISO,104 updatedAt: NOW_ISO,
105 archivedAt: null,
105 deletedAt: null,106 deletedAt: null,
106 messages: [107 messages: [
107 {108 {
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 {...@@ -380,6 +380,7 @@ function makeThread(overrides: Partial<Thread> = {}): Thread {
380 proposedPlans: [],380 proposedPlans: [],
381 error: null,381 error: null,
382 createdAt: "2026-03-09T10:00:00.000Z",382 createdAt: "2026-03-09T10:00:00.000Z",
383 archivedAt: null,
383 updatedAt: "2026-03-09T10:00:00.000Z",384 updatedAt: "2026-03-09T10:00:00.000Z",
384 latestTurn: null,385 latestTurn: null,
385 branch: null,386 branch: null,
...@@ -697,6 +698,43 @@ describe("sortProjectsForSidebar", () => {...@@ -697,6 +698,43 @@ describe("sortProjectsForSidebar", () => {
697 ]);698 ]);
698 });699 });
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
700 it("returns the project timestamp when no threads are present", () => {738 it("returns the project timestamp when no threads are present", () => {
701 const timestamp = getProjectSortTimestamp(739 const timestamp = getProjectSortTimestamp(
702 makeProject({ updatedAt: "2026-03-09T10:10:00.000Z" }),740 makeProject({ updatedAt: "2026-03-09T10:10:00.000Z" }),
apps/web/src/components/Sidebar.tsx+336-408
...@@ -1,5 +1,5 @@...@@ -1,5 +1,5 @@
1import {1import {
2 ArrowLeftIcon,2 ArchiveIcon,
3 ArrowUpDownIcon,3 ArrowUpDownIcon,
4 ChevronRightIcon,4 ChevronRightIcon,
5 FolderIcon,5 FolderIcon,
...@@ -11,6 +11,7 @@ import {...@@ -11,6 +11,7 @@ import {
11 TerminalIcon,11 TerminalIcon,
12 TriangleAlertIcon,12 TriangleAlertIcon,
13} from "lucide-react";13} from "lucide-react";
14import { ProjectFavicon } from "./ProjectFavicon";
14import { autoAnimate } from "@formkit/auto-animate";15import { autoAnimate } from "@formkit/auto-animate";
15import { useCallback, useEffect, useMemo, useRef, useState, type MouseEvent } from "react";16import { useCallback, useEffect, useMemo, useRef, useState, type MouseEvent } from "react";
16import {17import {
...@@ -36,7 +37,7 @@ import {...@@ -36,7 +37,7 @@ import {
36 type GitStatusResult,37 type GitStatusResult,
37 type ResolvedKeybindingsConfig,38 type ResolvedKeybindingsConfig,
38} from "@t3tools/contracts";39} from "@t3tools/contracts";
39import { useMutation, useQueries, useQuery, useQueryClient } from "@tanstack/react-query";40import { useQueries, useQuery } from "@tanstack/react-query";
40import { useLocation, useNavigate, useParams } from "@tanstack/react-router";41import { useLocation, useNavigate, useParams } from "@tanstack/react-router";
41import {42import {
42 type SidebarProjectSortOrder,43 type SidebarProjectSortOrder,
...@@ -48,13 +49,16 @@ import { isLinuxPlatform, isMacPlatform, newCommandId, newProjectId } from "../l...@@ -48,13 +49,16 @@ import { isLinuxPlatform, isMacPlatform, newCommandId, newProjectId } from "../l
48import { useStore } from "../store";49import { useStore } from "../store";
49import { shortcutLabelForCommand } from "../keybindings";50import { shortcutLabelForCommand } from "../keybindings";
50import { derivePendingApprovals, derivePendingUserInputs } from "../session-logic";51import { derivePendingApprovals, derivePendingUserInputs } from "../session-logic";
51import { gitRemoveWorktreeMutationOptions, gitStatusQueryOptions } from "../lib/gitReactQuery";52import { gitStatusQueryOptions } from "../lib/gitReactQuery";
52import { serverConfigQueryOptions } from "../lib/serverReactQuery";53import { serverConfigQueryOptions } from "../lib/serverReactQuery";
53import { readNativeApi } from "../nativeApi";54import { readNativeApi } from "../nativeApi";
54import { useComposerDraftStore } from "../composerDraftStore";55import { useComposerDraftStore } from "../composerDraftStore";
55import { useHandleNewThread } from "../hooks/useHandleNewThread";56import { useHandleNewThread } from "../hooks/useHandleNewThread";
57import { useThreadActions } from "../hooks/useThreadActions";
56import { selectThreadTerminalState, useTerminalStateStore } from "../terminalStateStore";58import { selectThreadTerminalState, useTerminalStateStore } from "../terminalStateStore";
57import { toastManager } from "./ui/toast";59import { toastManager } from "./ui/toast";
60import { formatRelativeTimeLabel } from "../timestampFormat";
61import { SettingsSidebarNav } from "./settings/SettingsSidebarNav";
58import {62import {
59 getArm64IntelBuildWarningDescription,63 getArm64IntelBuildWarningDescription,
60 getDesktopUpdateActionError,64 getDesktopUpdateActionError,
...@@ -87,10 +91,8 @@ import {...@@ -87,10 +91,8 @@ import {
87 SidebarTrigger,91 SidebarTrigger,
88} from "./ui/sidebar";92} from "./ui/sidebar";
89import { useThreadSelectionStore } from "../threadSelectionStore";93import { useThreadSelectionStore } from "../threadSelectionStore";
90import { formatWorktreePathForDisplay, getOrphanedWorktreePathForThread } from "../worktreeCleanup";
91import { isNonEmpty as isNonEmptyString } from "effect/String";94import { isNonEmpty as isNonEmptyString } from "effect/String";
92import {95import {
93 getFallbackThreadIdAfterDelete,
94 getVisibleThreadsForProject,96 getVisibleThreadsForProject,
95 resolveProjectStatusIndicator,97 resolveProjectStatusIndicator,
96 resolveSidebarNewThreadEnvMode,98 resolveSidebarNewThreadEnvMode,
...@@ -118,18 +120,6 @@ const SIDEBAR_LIST_ANIMATION_OPTIONS = {...@@ -118,18 +120,6 @@ const SIDEBAR_LIST_ANIMATION_OPTIONS = {
118 duration: 180,120 duration: 180,
119 easing: "ease-out",121 easing: "ease-out",
120} as const;122} 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
133interface TerminalStatusIndicator {123interface TerminalStatusIndicator {
134 label: "Terminal process running";124 label: "Terminal process running";
135 colorClass: string;125 colorClass: string;
...@@ -204,54 +194,6 @@ function T3Wordmark() {...@@ -204,54 +194,6 @@ function T3Wordmark() {
204 );194 );
205}195}
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
255type SortableProjectHandleProps = Pick<197type SortableProjectHandleProps = Pick<
256 ReturnType<typeof useSortable>,198 ReturnType<typeof useSortable>,
257 "attributes" | "listeners" | "setActivatorNodeRef"199 "attributes" | "listeners" | "setActivatorNodeRef"
...@@ -372,18 +314,16 @@ export default function Sidebar() {...@@ -372,18 +314,16 @@ export default function Sidebar() {
372 (store) => store.getDraftThreadByProjectId,314 (store) => store.getDraftThreadByProjectId,
373 );315 );
374 const terminalStateByThreadId = useTerminalStateStore((state) => state.terminalStateByThreadId);316 const terminalStateByThreadId = useTerminalStateStore((state) => state.terminalStateByThreadId);
375 const clearTerminalState = useTerminalStateStore((state) => state.clearTerminalState);
376 const clearProjectDraftThreadId = useComposerDraftStore(317 const clearProjectDraftThreadId = useComposerDraftStore(
377 (store) => store.clearProjectDraftThreadId,318 (store) => store.clearProjectDraftThreadId,
378 );319 );
379 const clearProjectDraftThreadById = useComposerDraftStore(
380 (store) => store.clearProjectDraftThreadById,
381 );
382 const navigate = useNavigate();320 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");
384 const appSettings = useSettings();323 const appSettings = useSettings();
385 const { updateSettings } = useUpdateSettings();324 const { updateSettings } = useUpdateSettings();
386 const { handleNewThread } = useHandleNewThread();325 const { handleNewThread } = useHandleNewThread();
326 const { archiveThread, deleteThread } = useThreadActions();
387 const routeThreadId = useParams({327 const routeThreadId = useParams({
388 strict: false,328 strict: false,
389 select: (params) => (params.threadId ? ThreadId.makeUnsafe(params.threadId) : null),329 select: (params) => (params.threadId ? ThreadId.makeUnsafe(params.threadId) : null),
...@@ -392,8 +332,6 @@ export default function Sidebar() {...@@ -392,8 +332,6 @@ export default function Sidebar() {
392 ...serverConfigQueryOptions(),332 ...serverConfigQueryOptions(),
393 select: (config) => config.keybindings,333 select: (config) => config.keybindings,
394 });334 });
395 const queryClient = useQueryClient();
396 const removeWorktreeMutation = useMutation(gitRemoveWorktreeMutationOptions({ queryClient }));
397 const [addingProject, setAddingProject] = useState(false);335 const [addingProject, setAddingProject] = useState(false);
398 const [newCwd, setNewCwd] = useState("");336 const [newCwd, setNewCwd] = useState("");
399 const [isPickingFolder, setIsPickingFolder] = useState(false);337 const [isPickingFolder, setIsPickingFolder] = useState(false);
...@@ -402,6 +340,8 @@ export default function Sidebar() {...@@ -402,6 +340,8 @@ export default function Sidebar() {
402 const addProjectInputRef = useRef<HTMLInputElement | null>(null);340 const addProjectInputRef = useRef<HTMLInputElement | null>(null);
403 const [renamingThreadId, setRenamingThreadId] = useState<ThreadId | null>(null);341 const [renamingThreadId, setRenamingThreadId] = useState<ThreadId | null>(null);
404 const [renamingTitle, setRenamingTitle] = useState("");342 const [renamingTitle, setRenamingTitle] = useState("");
343 const [hoveredThreadId, setHoveredThreadId] = useState<ThreadId | null>(null);
344 const [confirmingArchiveThreadId, setConfirmingArchiveThreadId] = useState<ThreadId | null>(null);
405 const [expandedThreadListsByProject, setExpandedThreadListsByProject] = useState<345 const [expandedThreadListsByProject, setExpandedThreadListsByProject] = useState<
406 ReadonlySet<ProjectId>346 ReadonlySet<ProjectId>
407 >(() => new Set());347 >(() => new Set());
...@@ -493,10 +433,25 @@ export default function Sidebar() {...@@ -493,10 +433,25 @@ export default function Sidebar() {
493 });433 });
494 }, []);434 }, []);
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
496 const focusMostRecentThreadForProject = useCallback(451 const focusMostRecentThreadForProject = useCallback(
497 (projectId: ProjectId) => {452 (projectId: ProjectId) => {
498 const latestThread = sortThreadsForSidebar(453 const latestThread = sortThreadsForSidebar(
499 threads.filter((thread) => thread.projectId === projectId),454 threads.filter((thread) => thread.projectId === projectId && thread.archivedAt === null),
500 appSettings.sidebarThreadSortOrder,455 appSettings.sidebarThreadSortOrder,
501 )[0];456 )[0];
502 if (!latestThread) return;457 if (!latestThread) return;
...@@ -627,7 +582,10 @@ export default function Sidebar() {...@@ -627,7 +582,10 @@ export default function Sidebar() {
627582
628 const trimmed = newTitle.trim();583 const trimmed = newTitle.trim();
629 if (trimmed.length === 0) {584 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 });
631 finishRename();589 finishRename();
632 return;590 return;
633 }591 }
...@@ -659,129 +617,9 @@ export default function Sidebar() {...@@ -659,129 +617,9 @@ export default function Sidebar() {
659 [],617 [],
660 );618 );
661619
662 /**620 const { copyToClipboard: copyThreadIdToClipboard } = useCopyToClipboard<{
663 * Delete a single thread: stop session, close terminal, dispatch delete,621 threadId: ThreadId;
664 * clean up drafts/state, and optionally remove orphaned worktree.622 }>({
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 }>({
785 onCopy: (ctx) => {623 onCopy: (ctx) => {
786 toastManager.add({624 toastManager.add({
787 type: "success",625 type: "success",
...@@ -797,7 +635,9 @@ export default function Sidebar() {...@@ -797,7 +635,9 @@ export default function Sidebar() {
797 });635 });
798 },636 },
799 });637 });
800 const { copyToClipboard: copyPathToClipboard } = useCopyToClipboard<{ path: string }>({638 const { copyToClipboard: copyPathToClipboard } = useCopyToClipboard<{
639 path: string;
640 }>({
801 onCopy: (ctx) => {641 onCopy: (ctx) => {
802 toastManager.add({642 toastManager.add({
803 type: "success",643 type: "success",
...@@ -1098,9 +938,13 @@ export default function Sidebar() {...@@ -1098,9 +938,13 @@ export default function Sidebar() {
1098 suppressProjectClickAfterDragRef.current = false;938 suppressProjectClickAfterDragRef.current = false;
1099 }, []);939 }, []);
1100940
941 const visibleThreads = useMemo(
942 () => threads.filter((thread) => thread.archivedAt === null),
943 [threads],
944 );
1101 const sortedProjects = useMemo(945 const sortedProjects = useMemo(
1102 () => sortProjectsForSidebar(projects, threads, appSettings.sidebarProjectSortOrder),946 () => sortProjectsForSidebar(projects, visibleThreads, appSettings.sidebarProjectSortOrder),
1103 [appSettings.sidebarProjectSortOrder, projects, threads],947 [appSettings.sidebarProjectSortOrder, projects, visibleThreads],
1104 );948 );
1105 const isManualProjectSorting = appSettings.sidebarProjectSortOrder === "manual";949 const isManualProjectSorting = appSettings.sidebarProjectSortOrder === "manual";
1106950
...@@ -1109,7 +953,7 @@ export default function Sidebar() {...@@ -1109,7 +953,7 @@ export default function Sidebar() {
1109 dragHandleProps: SortableProjectHandleProps | null,953 dragHandleProps: SortableProjectHandleProps | null,
1110 ) {954 ) {
1111 const projectThreads = sortThreadsForSidebar(955 const projectThreads = sortThreadsForSidebar(
1112 threads.filter((thread) => thread.projectId === project.id),956 visibleThreads.filter((thread) => thread.projectId === project.id),
1113 appSettings.sidebarThreadSortOrder,957 appSettings.sidebarThreadSortOrder,
1114 );958 );
1115 const projectStatus = resolveProjectStatusIndicator(959 const projectStatus = resolveProjectStatusIndicator(
...@@ -1128,18 +972,22 @@ export default function Sidebar() {...@@ -1128,18 +972,22 @@ export default function Sidebar() {
1128 ? (projectThreads.find((thread) => thread.id === activeThreadId) ?? null)972 ? (projectThreads.find((thread) => thread.id === activeThreadId) ?? null)
1129 : null;973 : null;
1130 const shouldShowThreadPanel = project.expanded || pinnedCollapsedThread !== null;974 const shouldShowThreadPanel = project.expanded || pinnedCollapsedThread !== null;
1131 const { hasHiddenThreads, visibleThreads } = getVisibleThreadsForProject({975 const { hasHiddenThreads, visibleThreads: visibleProjectThreads } = getVisibleThreadsForProject(
1132 threads: projectThreads,976 {
1133 activeThreadId,977 threads: projectThreads,
1134 isThreadListExpanded,978 activeThreadId,
1135 previewLimit: THREAD_PREVIEW_LIMIT,979 isThreadListExpanded,
1136 });980 previewLimit: THREAD_PREVIEW_LIMIT,
981 },
982 );
1137 const orderedProjectThreadIds = projectThreads.map((thread) => thread.id);983 const orderedProjectThreadIds = projectThreads.map((thread) => thread.id);
1138 const renderedThreads = pinnedCollapsedThread ? [pinnedCollapsedThread] : visibleThreads;984 const renderedThreads = pinnedCollapsedThread ? [pinnedCollapsedThread] : visibleProjectThreads;
1139 const renderThreadRow = (thread: (typeof projectThreads)[number]) => {985 const renderThreadRow = (thread: (typeof projectThreads)[number]) => {
1140 const isActive = routeThreadId === thread.id;986 const isActive = routeThreadId === thread.id;
1141 const isSelected = selectedThreadIds.has(thread.id);987 const isSelected = selectedThreadIds.has(thread.id);
1142 const isHighlighted = isActive || isSelected;988 const isHighlighted = isActive || isSelected;
989 const isThreadRunning =
990 thread.session?.status === "running" && thread.session.activeTurnId != null;
1143 const threadStatus = resolveThreadStatusPill({991 const threadStatus = resolveThreadStatusPill({
1144 thread,992 thread,
1145 hasPendingApprovals: derivePendingApprovals(thread.activities).length > 0,993 hasPendingApprovals: derivePendingApprovals(thread.activities).length > 0,
...@@ -1151,15 +999,26 @@ export default function Sidebar() {...@@ -1151,15 +999,26 @@ export default function Sidebar() {
1151 );999 );
11521000
1153 return (1001 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 >
1155 <SidebarMenuSubButton1014 <SidebarMenuSubButton
1156 render={<div role="button" tabIndex={0} />}1015 render={<div role="button" tabIndex={0} />}
1157 size="sm"1016 size="sm"
1158 isActive={isActive}1017 isActive={isActive}
1159 className={resolveThreadRowClassName({1018 className={`${resolveThreadRowClassName({
1160 isActive,1019 isActive,
1161 isSelected,1020 isSelected,
1162 })}1021 })} relative`}
1163 onClick={(event) => {1022 onClick={(event) => {
1164 handleThreadClick(event, thread.id, orderedProjectThreadIds);1023 handleThreadClick(event, thread.id, orderedProjectThreadIds);
1165 }}1024 }}
...@@ -1177,6 +1036,7 @@ export default function Sidebar() {...@@ -1177,6 +1036,7 @@ export default function Sidebar() {
1177 }}1036 }}
1178 onContextMenu={(event) => {1037 onContextMenu={(event) => {
1179 event.preventDefault();1038 event.preventDefault();
1039 setHoveredThreadId((current) => (current === thread.id ? null : current));
1180 if (selectedThreadIds.size > 0 && selectedThreadIds.has(thread.id)) {1040 if (selectedThreadIds.size > 0 && selectedThreadIds.has(thread.id)) {
1181 void handleMultiSelectContextMenu({1041 void handleMultiSelectContextMenu({
1182 x: event.clientX,1042 x: event.clientX,
...@@ -1273,15 +1133,86 @@ export default function Sidebar() {...@@ -1273,15 +1133,86 @@ export default function Sidebar() {
1273 />1133 />
1274 </span>1134 </span>
1275 )}1135 )}
1276 <span1136 <div className="flex min-w-12 justify-end">
1277 className={`text-[10px] ${1137 {confirmingArchiveThreadId === thread.id && !isThreadRunning ? (
1278 isHighlighted1138 <button
1279 ? "text-foreground/72 dark:text-foreground/82"1139 type="button"
1280 : "text-muted-foreground/40"1140 data-thread-selection-safe
1281 }`}1141 aria-label={`Confirm archive ${thread.title}`}
1282 >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"
1283 {formatRelativeTime(thread.updatedAt ?? thread.createdAt)}1143 onPointerDown={(event) => {
1284 </span>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>
1285 </div>1216 </div>
1286 </SidebarMenuSubButton>1217 </SidebarMenuSubButton>
1287 </SidebarMenuSubItem>1218 </SidebarMenuSubItem>
...@@ -1349,7 +1280,7 @@ export default function Sidebar() {...@@ -1349,7 +1280,7 @@ export default function Sidebar() {
1349 />1280 />
1350 }1281 }
1351 showOnHover1282 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"
1353 onClick={(event) => {1284 onClick={(event) => {
1354 event.preventDefault();1285 event.preventDefault();
1355 event.stopPropagation();1286 event.stopPropagation();
...@@ -1658,202 +1589,199 @@ export default function Sidebar() {...@@ -1658,202 +1589,199 @@ export default function Sidebar() {
1658 </SidebarHeader>1589 </SidebarHeader>
1659 )}1590 )}
16601591
1661 <SidebarContent className="gap-0">1592 {isOnSettings ? (
1662 {showArm64IntelBuildWarning && arm64IntelBuildWarningDescription ? (1593 <SettingsSidebarNav pathname={pathname} />
1663 <SidebarGroup className="px-2 pt-2 pb-0">1594 ) : (
1664 <Alert variant="warning" className="rounded-2xl border-warning/40 bg-warning/8">1595 <>
1665 <TriangleAlertIcon />1596 <SidebarContent className="gap-0">
1666 <AlertTitle>Intel build on Apple Silicon</AlertTitle>1597 {showArm64IntelBuildWarning && arm64IntelBuildWarningDescription ? (
1667 <AlertDescription>{arm64IntelBuildWarningDescription}</AlertDescription>1598 <SidebarGroup className="px-2 pt-2 pb-0">
1668 {desktopUpdateButtonAction !== "none" ? (1599 <Alert variant="warning" className="rounded-2xl border-warning/40 bg-warning/8">
1669 <AlertAction>1600 <TriangleAlertIcon />
1670 <Button1601 <AlertTitle>Intel build on Apple Silicon</AlertTitle>
1671 size="xs"1602 <AlertDescription>{arm64IntelBuildWarningDescription}</AlertDescription>
1672 variant="outline"1603 {desktopUpdateButtonAction !== "none" ? (
1673 disabled={desktopUpdateButtonDisabled}1604 <AlertAction>
1674 onClick={handleDesktopUpdateButtonClick}1605 <Button
1675 >1606 size="xs"
1676 {desktopUpdateButtonAction === "download"1607 variant="outline"
1677 ? "Download ARM build"1608 disabled={desktopUpdateButtonDisabled}
1678 : "Install ARM build"}1609 onClick={handleDesktopUpdateButtonClick}
1679 </Button>1610 >
1680 </AlertAction>1611 {desktopUpdateButtonAction === "download"
1681 ) : null}1612 ? "Download ARM build"
1682 </Alert>1613 : "Install ARM build"}
1683 </SidebarGroup>1614 </Button>
1684 ) : null}1615 </AlertAction>
1685 <SidebarGroup className="px-2 py-2">1616 ) : null}
1686 <div className="mb-1 flex items-center justify-between px-2">1617 </Alert>
1687 <span className="text-[10px] font-medium uppercase tracking-wider text-muted-foreground/60">1618 </SidebarGroup>
1688 Projects1619 ) : null}
1689 </span>1620 <SidebarGroup className="px-2 py-2">
1690 <div className="flex items-center gap-1">1621 <div className="mb-1 flex items-center justify-between pl-2 pr-1.5">
1691 <ProjectSortMenu1622 <span className="text-[10px] font-medium uppercase tracking-wider text-muted-foreground/60">
1692 projectSortOrder={appSettings.sidebarProjectSortOrder}1623 Projects
1693 threadSortOrder={appSettings.sidebarThreadSortOrder}1624 </span>
1694 onProjectSortOrderChange={(sortOrder) => {1625 <div className="flex items-center gap-1">
1695 updateSettings({ sidebarProjectSortOrder: sortOrder });1626 <ProjectSortMenu
1696 }}1627 projectSortOrder={appSettings.sidebarProjectSortOrder}
1697 onThreadSortOrderChange={(sortOrder) => {1628 threadSortOrder={appSettings.sidebarThreadSortOrder}
1698 updateSettings({ sidebarThreadSortOrder: sortOrder });1629 onProjectSortOrderChange={(sortOrder) => {
1699 }}1630 updateSettings({ sidebarProjectSortOrder: sortOrder });
1700 />1631 }}
1701 <Tooltip>1632 onThreadSortOrderChange={(sortOrder) => {
1702 <TooltipTrigger1633 updateSettings({ sidebarThreadSortOrder: sortOrder });
1703 render={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 && (
1704 <button1666 <button
1705 type="button"1667 type="button"
1706 aria-label={shouldShowProjectPathEntry ? "Cancel add project" : "Add project"}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"
1707 aria-pressed={shouldShowProjectPathEntry}1669 onClick={() => void handlePickFolder()}
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"1670 disabled={isPickingFolder || isAddingProject}
1709 onClick={handleStartAddProject}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
1710 />1698 />
1711 }1699 <button
1712 >1700 type="button"
1713 <PlusIcon1701 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"
1714 className={`size-3.5 transition-transform duration-150 ${1702 onClick={handleAddProject}
1715 shouldShowProjectPathEntry ? "rotate-45" : "rotate-0"1703 disabled={!canAddProject}
1716 }`}1704 >
1717 />1705 {isAddingProject ? "Adding..." : "Add"}
1718 </TooltipTrigger>1706 </button>
1719 <TooltipPopup side="right">1707 </div>
1720 {shouldShowProjectPathEntry ? "Cancel add project" : "Add project"}1708 {addProjectError && (
1721 </TooltipPopup>1709 <p className="mt-1 px-0.5 text-[11px] leading-tight text-red-400">
1722 </Tooltip>1710 {addProjectError}
1723 </div>1711 </p>
1724 </div>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 && (1728 {isManualProjectSorting ? (
1727 <div className="mb-2 px-1">1729 <DndContext
1728 {isElectron && (1730 sensors={projectDnDSensors}
1729 <button1731 collisionDetection={projectCollisionDetection}
1730 type="button"1732 modifiers={[restrictToVerticalAxis, restrictToFirstScrollableAncestor]}
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"1733 onDragStart={handleProjectDragStart}
1732 onClick={() => void handlePickFolder()}1734 onDragEnd={handleProjectDragEnd}
1733 disabled={isPickingFolder || isAddingProject}1735 onDragCancel={handleProjectDragCancel}
1734 >1736 >
1735 <FolderIcon className="size-3.5" />1737 <SidebarMenu>
1736 {isPickingFolder ? "Picking folder..." : "Browse for folder"}1738 <SortableContext
1737 </button>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>
1738 )}1758 )}
1739 <div className="flex gap-1.5">1759
1740 <input1760 {projects.length === 0 && !shouldShowProjectPathEntry && (
1741 ref={addProjectInputRef}1761 <div className="px-2 pt-4 text-center text-xs text-muted-foreground/60">
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 ${1762 No projects yet
1743 addProjectError1763 </div>
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>
1775 )}1764 )}
1776 <div className="mt-1.5 px-0.5">1765 </SidebarGroup>
1777 <button1766 </SidebarContent>
1778 type="button"1767
1779 className="text-[11px] text-muted-foreground/50 transition-colors hover:text-muted-foreground"1768 <SidebarSeparator />
1780 onClick={() => {1769 <SidebarFooter className="p-2">
1781 setAddingProject(false);1770 <SidebarMenu>
1782 setAddProjectError(null);1771 <SidebarMenuItem>
1783 }}1772 <SidebarMenuButton
1784 >1773 size="sm"
1785 Cancel1774 className="gap-2 px-2 py-1.5 text-muted-foreground/70 hover:bg-accent hover:text-foreground"
1786 </button>1775 onClick={() => void navigate({ to: "/settings" })}
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}
1804 >1776 >
1805 {sortedProjects.map((project) => (1777 <SettingsIcon className="size-3.5" />
1806 <SortableProjectItem key={project.id} projectId={project.id}>1778 <span className="text-xs">Settings</span>
1807 {(dragHandleProps) => renderProjectItem(project, dragHandleProps)}1779 </SidebarMenuButton>
1808 </SortableProjectItem>1780 </SidebarMenuItem>
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 ))}
1820 </SidebarMenu>1781 </SidebarMenu>
1821 )}1782 </SidebarFooter>
18221783 </>
1823 {projects.length === 0 && !shouldShowProjectPathEntry && (1784 )}
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>
1857 </>1785 </>
1858 );1786 );
1859}1787}
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>(...@@ -44,10 +44,16 @@ export function showContextMenuFallback<T extends string>(
44 btn.type = "button";44 btn.type = "button";
45 btn.textContent = item.label;45 btn.textContent = item.label;
46 const isDestructiveAction = item.destructive === true || item.id === "delete";46 const isDestructiveAction = item.destructive === true || item.id === "delete";
47 btn.className = isDestructiveAction47 const isDisabled = item.disabled === true;
48 ? "flex w-full items-center gap-2 px-3 py-1.5 text-left text-[11px] text-destructive hover:bg-accent cursor-default"48 btn.disabled = isDisabled;
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";49 btn.className = isDisabled
50 btn.addEventListener("click", () => cleanup(item.id));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 }
51 menu.appendChild(btn);57 menu.appendChild(btn);
52 }58 }
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(...@@ -202,6 +202,10 @@ export function buildLegacyClientSettingsMigrationPatch(
202): Partial<DeepMutable<ClientSettings>> {202): Partial<DeepMutable<ClientSettings>> {
203 const patch: Partial<DeepMutable<ClientSettings>> = {};203 const patch: Partial<DeepMutable<ClientSettings>> = {};
204204
205 if (Predicate.isBoolean(legacySettings.confirmThreadArchive)) {
206 patch.confirmThreadArchive = legacySettings.confirmThreadArchive;
207 }
208
205 if (Predicate.isBoolean(legacySettings.confirmThreadDelete)) {209 if (Predicate.isBoolean(legacySettings.confirmThreadDelete)) {
206 patch.confirmThreadDelete = legacySettings.confirmThreadDelete;210 patch.confirmThreadDelete = legacySettings.confirmThreadDelete;
207 }211 }
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 {...@@ -23,25 +23,42 @@ export function createMemoryStorage(): StateStorage {
23 };23 };
24}24}
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
26export function createDebouncedStorage(42export function createDebouncedStorage(
27 baseStorage: StateStorage,43 baseStorage: Partial<StateStorage> | null | undefined,
28 debounceMs: number = 300,44 debounceMs: number = 300,
29): DebouncedStorage {45): DebouncedStorage {
46 const resolvedStorage = resolveStorage(baseStorage);
30 const debouncedSetItem = new Debouncer(47 const debouncedSetItem = new Debouncer(
31 (name: string, value: string) => {48 (name: string, value: string) => {
32 baseStorage.setItem(name, value);49 resolvedStorage.setItem(name, value);
33 },50 },
34 { wait: debounceMs },51 { wait: debounceMs },
35 );52 );
3653
37 return {54 return {
38 getItem: (name) => baseStorage.getItem(name),55 getItem: (name) => resolvedStorage.getItem(name),
39 setItem: (name, value) => {56 setItem: (name, value) => {
40 debouncedSetItem.maybeExecute(name, value);57 debouncedSetItem.maybeExecute(name, value);
41 },58 },
42 removeItem: (name) => {59 removeItem: (name) => {
43 debouncedSetItem.cancel();60 debouncedSetItem.cancel();
44 baseStorage.removeItem(name);61 resolvedStorage.removeItem(name);
45 },62 },
46 flush: () => {63 flush: () => {
47 debouncedSetItem.flush();64 debouncedSetItem.flush();
apps/web/src/routeTree.gen.ts+82-19
...@@ -9,11 +9,18 @@...@@ -9,11 +9,18 @@
9// Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified.9// Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified.
1010
11import { Route as rootRouteImport } from './routes/__root'11import { Route as rootRouteImport } from './routes/__root'
12import { Route as SettingsRouteImport } from './routes/settings'
12import { Route as ChatRouteImport } from './routes/_chat'13import { Route as ChatRouteImport } from './routes/_chat'
13import { Route as ChatIndexRouteImport } from './routes/_chat.index'14import { 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'
15import { Route as ChatThreadIdRouteImport } from './routes/_chat.$threadId'17import { Route as ChatThreadIdRouteImport } from './routes/_chat.$threadId'
1618
19const SettingsRoute = SettingsRouteImport.update({
20 id: '/settings',
21 path: '/settings',
22 getParentRoute: () => rootRouteImport,
23} as any)
17const ChatRoute = ChatRouteImport.update({24const ChatRoute = ChatRouteImport.update({
18 id: '/_chat',25 id: '/_chat',
19 getParentRoute: () => rootRouteImport,26 getParentRoute: () => rootRouteImport,
...@@ -23,10 +30,15 @@ const ChatIndexRoute = ChatIndexRouteImport.update({...@@ -23,10 +30,15 @@ const ChatIndexRoute = ChatIndexRouteImport.update({
23 path: '/',30 path: '/',
24 getParentRoute: () => ChatRoute,31 getParentRoute: () => ChatRoute,
25} as any)32} as any)
26const ChatSettingsRoute = ChatSettingsRouteImport.update({33const SettingsGeneralRoute = SettingsGeneralRouteImport.update({
27 id: '/settings',34 id: '/general',
28 path: '/settings',35 path: '/general',
29 getParentRoute: () => ChatRoute,36 getParentRoute: () => SettingsRoute,
37} as any)
38const SettingsArchivedRoute = SettingsArchivedRouteImport.update({
39 id: '/archived',
40 path: '/archived',
41 getParentRoute: () => SettingsRoute,
30} as any)42} as any)
31const ChatThreadIdRoute = ChatThreadIdRouteImport.update({43const ChatThreadIdRoute = ChatThreadIdRouteImport.update({
32 id: '/$threadId',44 id: '/$threadId',
...@@ -36,35 +48,66 @@ const ChatThreadIdRoute = ChatThreadIdRouteImport.update({...@@ -36,35 +48,66 @@ const ChatThreadIdRoute = ChatThreadIdRouteImport.update({
3648
37export interface FileRoutesByFullPath {49export interface FileRoutesByFullPath {
38 '/': typeof ChatIndexRoute50 '/': typeof ChatIndexRoute
51 '/settings': typeof SettingsRouteWithChildren
39 '/$threadId': typeof ChatThreadIdRoute52 '/$threadId': typeof ChatThreadIdRoute
40 '/settings': typeof ChatSettingsRoute53 '/settings/archived': typeof SettingsArchivedRoute
54 '/settings/general': typeof SettingsGeneralRoute
41}55}
42export interface FileRoutesByTo {56export interface FileRoutesByTo {
57 '/settings': typeof SettingsRouteWithChildren
43 '/$threadId': typeof ChatThreadIdRoute58 '/$threadId': typeof ChatThreadIdRoute
44 '/settings': typeof ChatSettingsRoute59 '/settings/archived': typeof SettingsArchivedRoute
60 '/settings/general': typeof SettingsGeneralRoute
45 '/': typeof ChatIndexRoute61 '/': typeof ChatIndexRoute
46}62}
47export interface FileRoutesById {63export interface FileRoutesById {
48 __root__: typeof rootRouteImport64 __root__: typeof rootRouteImport
49 '/_chat': typeof ChatRouteWithChildren65 '/_chat': typeof ChatRouteWithChildren
66 '/settings': typeof SettingsRouteWithChildren
50 '/_chat/$threadId': typeof ChatThreadIdRoute67 '/_chat/$threadId': typeof ChatThreadIdRoute
51 '/_chat/settings': typeof ChatSettingsRoute68 '/settings/archived': typeof SettingsArchivedRoute
69 '/settings/general': typeof SettingsGeneralRoute
52 '/_chat/': typeof ChatIndexRoute70 '/_chat/': typeof ChatIndexRoute
53}71}
54export interface FileRouteTypes {72export interface FileRouteTypes {
55 fileRoutesByFullPath: FileRoutesByFullPath73 fileRoutesByFullPath: FileRoutesByFullPath
56 fullPaths: '/' | '/$threadId' | '/settings'74 fullPaths:
75 | '/'
76 | '/settings'
77 | '/$threadId'
78 | '/settings/archived'
79 | '/settings/general'
57 fileRoutesByTo: FileRoutesByTo80 fileRoutesByTo: FileRoutesByTo
58 to: '/$threadId' | '/settings' | '/'81 to:
59 id: '__root__' | '/_chat' | '/_chat/$threadId' | '/_chat/settings' | '/_chat/'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/'
60 fileRoutesById: FileRoutesById95 fileRoutesById: FileRoutesById
61}96}
62export interface RootRouteChildren {97export interface RootRouteChildren {
63 ChatRoute: typeof ChatRouteWithChildren98 ChatRoute: typeof ChatRouteWithChildren
99 SettingsRoute: typeof SettingsRouteWithChildren
64}100}
65101
66declare module '@tanstack/react-router' {102declare module '@tanstack/react-router' {
67 interface FileRoutesByPath {103 interface FileRoutesByPath {
104 '/settings': {
105 id: '/settings'
106 path: '/settings'
107 fullPath: '/settings'
108 preLoaderRoute: typeof SettingsRouteImport
109 parentRoute: typeof rootRouteImport
110 }
68 '/_chat': {111 '/_chat': {
69 id: '/_chat'112 id: '/_chat'
70 path: ''113 path: ''
...@@ -79,12 +122,19 @@ declare module '@tanstack/react-router' {...@@ -79,12 +122,19 @@ declare module '@tanstack/react-router' {
79 preLoaderRoute: typeof ChatIndexRouteImport122 preLoaderRoute: typeof ChatIndexRouteImport
80 parentRoute: typeof ChatRoute123 parentRoute: typeof ChatRoute
81 }124 }
82 '/_chat/settings': {125 '/settings/general': {
83 id: '/_chat/settings'126 id: '/settings/general'
84 path: '/settings'127 path: '/general'
85 fullPath: '/settings'128 fullPath: '/settings/general'
86 preLoaderRoute: typeof ChatSettingsRouteImport129 preLoaderRoute: typeof SettingsGeneralRouteImport
87 parentRoute: typeof ChatRoute130 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
88 }138 }
89 '/_chat/$threadId': {139 '/_chat/$threadId': {
90 id: '/_chat/$threadId'140 id: '/_chat/$threadId'
...@@ -98,20 +148,33 @@ declare module '@tanstack/react-router' {...@@ -98,20 +148,33 @@ declare module '@tanstack/react-router' {
98148
99interface ChatRouteChildren {149interface ChatRouteChildren {
100 ChatThreadIdRoute: typeof ChatThreadIdRoute150 ChatThreadIdRoute: typeof ChatThreadIdRoute
101 ChatSettingsRoute: typeof ChatSettingsRoute
102 ChatIndexRoute: typeof ChatIndexRoute151 ChatIndexRoute: typeof ChatIndexRoute
103}152}
104153
105const ChatRouteChildren: ChatRouteChildren = {154const ChatRouteChildren: ChatRouteChildren = {
106 ChatThreadIdRoute: ChatThreadIdRoute,155 ChatThreadIdRoute: ChatThreadIdRoute,
107 ChatSettingsRoute: ChatSettingsRoute,
108 ChatIndexRoute: ChatIndexRoute,156 ChatIndexRoute: ChatIndexRoute,
109}157}
110158
111const ChatRouteWithChildren = ChatRoute._addFileChildren(ChatRouteChildren)159const 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
113const rootRouteChildren: RootRouteChildren = {175const rootRouteChildren: RootRouteChildren = {
114 ChatRoute: ChatRouteWithChildren,176 ChatRoute: ChatRouteWithChildren,
177 SettingsRoute: SettingsRouteWithChildren,
115}178}
116export const routeTree = rootRouteImport179export const routeTree = rootRouteImport
117 ._addFileChildren(rootRouteChildren)180 ._addFileChildren(rootRouteChildren)
apps/web/src/routes/__root.tsx+4-1
...@@ -11,6 +11,7 @@ import { QueryClient, useQueryClient } from "@tanstack/react-query";...@@ -11,6 +11,7 @@ import { QueryClient, useQueryClient } from "@tanstack/react-query";
11import { Throttler } from "@tanstack/react-pacer";11import { Throttler } from "@tanstack/react-pacer";
1212
13import { APP_DISPLAY_NAME } from "../branding";13import { APP_DISPLAY_NAME } from "../branding";
14import { AppSidebarLayout } from "../components/AppSidebarLayout";
14import { Button } from "../components/ui/button";15import { Button } from "../components/ui/button";
15import { AnchoredToastProvider, ToastProvider, toastManager } from "../components/ui/toast";16import { AnchoredToastProvider, ToastProvider, toastManager } from "../components/ui/toast";
16import { resolveAndPersistPreferredEditor } from "../editorPreferences";17import { resolveAndPersistPreferredEditor } from "../editorPreferences";
...@@ -54,7 +55,9 @@ function RootRouteView() {...@@ -54,7 +55,9 @@ function RootRouteView() {
54 <AnchoredToastProvider>55 <AnchoredToastProvider>
55 <EventRouter />56 <EventRouter />
56 <DesktopProjectBootstrap />57 <DesktopProjectBootstrap />
57 <Outlet />58 <AppSidebarLayout>
59 <Outlet />
60 </AppSidebarLayout>
58 </AnchoredToastProvider>61 </AnchoredToastProvider>
59 </ToastProvider>62 </ToastProvider>
60 );63 );
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 @@...@@ -1,9 +1,8 @@
1import { type ResolvedKeybindingsConfig } from "@t3tools/contracts";1import { type ResolvedKeybindingsConfig } from "@t3tools/contracts";
2import { useQuery } from "@tanstack/react-query";2import { useQuery } from "@tanstack/react-query";
3import { Outlet, createFileRoute, useNavigate } from "@tanstack/react-router";3import { Outlet, createFileRoute } from "@tanstack/react-router";
4import { useEffect } from "react";4import { useEffect } from "react";
55
6import ThreadSidebar from "../components/Sidebar";
7import { useHandleNewThread } from "../hooks/useHandleNewThread";6import { useHandleNewThread } from "../hooks/useHandleNewThread";
8import { isTerminalFocused } from "../lib/terminalFocus";7import { isTerminalFocused } from "../lib/terminalFocus";
9import { serverConfigQueryOptions } from "../lib/serverReactQuery";8import { serverConfigQueryOptions } from "../lib/serverReactQuery";
...@@ -12,12 +11,8 @@ import { selectThreadTerminalState, useTerminalStateStore } from "../terminalSta...@@ -12,12 +11,8 @@ import { selectThreadTerminalState, useTerminalStateStore } from "../terminalSta
12import { useThreadSelectionStore } from "../threadSelectionStore";11import { useThreadSelectionStore } from "../threadSelectionStore";
13import { resolveSidebarNewThreadEnvMode } from "~/components/Sidebar.logic";12import { resolveSidebarNewThreadEnvMode } from "~/components/Sidebar.logic";
14import { useSettings } from "~/hooks/useSettings";13import { useSettings } from "~/hooks/useSettings";
15import { Sidebar, SidebarProvider, SidebarRail } from "~/components/ui/sidebar";
1614
17const EMPTY_KEYBINDINGS: ResolvedKeybindingsConfig = [];15const 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
22function ChatRouteGlobalShortcuts() {17function ChatRouteGlobalShortcuts() {
23 const clearSelection = useThreadSelectionStore((state) => state.clearSelection);18 const clearSelection = useThreadSelectionStore((state) => state.clearSelection);
...@@ -94,43 +89,11 @@ function ChatRouteGlobalShortcuts() {...@@ -94,43 +89,11 @@ function ChatRouteGlobalShortcuts() {
94}89}
9590
96function ChatRouteLayout() {91function 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
115 return (92 return (
116 <SidebarProvider defaultOpen>93 <>
117 <ChatRouteGlobalShortcuts />94 <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>
132 <Outlet />95 <Outlet />
133 </SidebarProvider>96 </>
134 );97 );
135}98}
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 {...@@ -29,6 +29,7 @@ function makeThread(overrides: Partial<Thread> = {}): Thread {
29 proposedPlans: [],29 proposedPlans: [],
30 error: null,30 error: null,
31 createdAt: "2026-02-13T00:00:00.000Z",31 createdAt: "2026-02-13T00:00:00.000Z",
32 archivedAt: null,
32 latestTurn: null,33 latestTurn: null,
33 branch: null,34 branch: null,
34 worktreePath: null,35 worktreePath: null,
...@@ -72,6 +73,7 @@ function makeReadModelThread(overrides: Partial<OrchestrationReadModel["threads"...@@ -72,6 +73,7 @@ function makeReadModelThread(overrides: Partial<OrchestrationReadModel["threads"
72 latestTurn: null,73 latestTurn: null,
73 createdAt: "2026-02-27T00:00:00.000Z",74 createdAt: "2026-02-27T00:00:00.000Z",
74 updatedAt: "2026-02-27T00:00:00.000Z",75 updatedAt: "2026-02-27T00:00:00.000Z",
76 archivedAt: null,
75 deletedAt: null,77 deletedAt: null,
76 messages: [],78 messages: [],
77 activities: [],79 activities: [],
...@@ -270,6 +272,21 @@ describe("store read model sync", () => {...@@ -270,6 +272,21 @@ describe("store read model sync", () => {
270 expect(next.threads[0]?.updatedAt).toBe("2026-02-27T00:05:00.000Z");272 expect(next.threads[0]?.updatedAt).toBe("2026-02-27T00:05:00.000Z");
271 });273 });
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
273 it("preserves the current project order when syncing incoming read model updates", () => {290 it("preserves the current project order when syncing incoming read model updates", () => {
274 const project1 = ProjectId.makeUnsafe("project-1");291 const project1 = ProjectId.makeUnsafe("project-1");
275 const project2 = ProjectId.makeUnsafe("project-2");292 const project2 = ProjectId.makeUnsafe("project-2");
apps/web/src/store.ts+1
...@@ -299,6 +299,7 @@ export function syncServerReadModel(state: AppState, readModel: OrchestrationRea...@@ -299,6 +299,7 @@ export function syncServerReadModel(state: AppState, readModel: OrchestrationRea
299 })),299 })),
300 error: thread.session?.lastError ?? null,300 error: thread.session?.lastError ?? null,
301 createdAt: thread.createdAt,301 createdAt: thread.createdAt,
302 archivedAt: thread.archivedAt,
302 updatedAt: thread.updatedAt,303 updatedAt: thread.updatedAt,
303 latestTurn: thread.latestTurn,304 latestTurn: thread.latestTurn,
304 lastVisitedAt: existing?.lastVisitedAt ?? thread.updatedAt,305 lastVisitedAt: existing?.lastVisitedAt ?? thread.updatedAt,
apps/web/src/terminalStateStore.test.ts+1-3
...@@ -7,9 +7,7 @@ const THREAD_ID = ThreadId.makeUnsafe("thread-1");...@@ -7,9 +7,7 @@ const THREAD_ID = ThreadId.makeUnsafe("thread-1");
77
8describe("terminalStateStore actions", () => {8describe("terminalStateStore actions", () => {
9 beforeEach(() => {9 beforeEach(() => {
10 if (typeof localStorage !== "undefined") {10 useTerminalStateStore.persist.clearStorage();
11 localStorage.clear();
12 }
13 useTerminalStateStore.setState({ terminalStateByThreadId: {} });11 useTerminalStateStore.setState({ terminalStateByThreadId: {} });
14 });12 });
1513
apps/web/src/terminalStateStore.ts+6-1
...@@ -8,6 +8,7 @@...@@ -8,6 +8,7 @@
8import type { ThreadId } from "@t3tools/contracts";8import type { ThreadId } from "@t3tools/contracts";
9import { create } from "zustand";9import { create } from "zustand";
10import { createJSONStorage, persist } from "zustand/middleware";10import { createJSONStorage, persist } from "zustand/middleware";
11import { resolveStorage } from "./lib/storage";
11import {12import {
12 DEFAULT_THREAD_TERMINAL_HEIGHT,13 DEFAULT_THREAD_TERMINAL_HEIGHT,
13 DEFAULT_THREAD_TERMINAL_ID,14 DEFAULT_THREAD_TERMINAL_ID,
...@@ -27,6 +28,10 @@ interface ThreadTerminalState {...@@ -27,6 +28,10 @@ interface ThreadTerminalState {
2728
28const TERMINAL_STATE_STORAGE_KEY = "t3code:terminal-state:v1";29const TERMINAL_STATE_STORAGE_KEY = "t3code:terminal-state:v1";
2930
31function createTerminalStateStorage() {
32 return resolveStorage(typeof window !== "undefined" ? window.localStorage : undefined);
33}
34
30function normalizeTerminalIds(terminalIds: string[]): string[] {35function normalizeTerminalIds(terminalIds: string[]): string[] {
31 const ids = [...new Set(terminalIds.map((id) => id.trim()).filter((id) => id.length > 0))];36 const ids = [...new Set(terminalIds.map((id) => id.trim()).filter((id) => id.length > 0))];
32 return ids.length > 0 ? ids : [DEFAULT_THREAD_TERMINAL_ID];37 return ids.length > 0 ? ids : [DEFAULT_THREAD_TERMINAL_ID];
...@@ -542,7 +547,7 @@ export const useTerminalStateStore = create<TerminalStateStoreState>()(...@@ -542,7 +547,7 @@ export const useTerminalStateStore = create<TerminalStateStoreState>()(
542 {547 {
543 name: TERMINAL_STATE_STORAGE_KEY,548 name: TERMINAL_STATE_STORAGE_KEY,
544 version: 1,549 version: 1,
545 storage: createJSONStorage(() => localStorage),550 storage: createJSONStorage(createTerminalStateStorage),
546 partialize: (state) => ({551 partialize: (state) => ({
547 terminalStateByThreadId: state.terminalStateByThreadId,552 terminalStateByThreadId: state.terminalStateByThreadId,
548 }),553 }),
apps/web/src/timestampFormat.ts+5
...@@ -66,3 +66,8 @@ export function formatRelativeTime(isoDate: string): { value: string; suffix: st...@@ -66,3 +66,8 @@ export function formatRelativeTime(isoDate: string): { value: string; suffix: st
66 const days = Math.floor(hours / 24);66 const days = Math.floor(hours / 24);
67 return { value: `${days}d`, suffix: "ago" };67 return { value: `${days}d`, suffix: "ago" };
68}68}
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 {...@@ -101,6 +101,7 @@ export interface Thread {
101 proposedPlans: ProposedPlan[];101 proposedPlans: ProposedPlan[];
102 error: string | null;102 error: string | null;
103 createdAt: string;103 createdAt: string;
104 archivedAt: string | null;
104 updatedAt?: string | undefined;105 updatedAt?: string | undefined;
105 latestTurn: OrchestrationLatestTurn | null;106 latestTurn: OrchestrationLatestTurn | null;
106 lastVisitedAt?: string | undefined;107 lastVisitedAt?: string | undefined;
apps/web/src/worktreeCleanup.test.ts+1
...@@ -23,6 +23,7 @@ function makeThread(overrides: Partial<Thread> = {}): Thread {...@@ -23,6 +23,7 @@ function makeThread(overrides: Partial<Thread> = {}): Thread {
23 proposedPlans: [],23 proposedPlans: [],
24 error: null,24 error: null,
25 createdAt: "2026-02-13T00:00:00.000Z",25 createdAt: "2026-02-13T00:00:00.000Z",
26 archivedAt: null,
26 latestTurn: null,27 latestTurn: null,
27 branch: null,28 branch: null,
28 worktreePath: null,29 worktreePath: null,
packages/contracts/src/ipc.ts+1
...@@ -57,6 +57,7 @@ export interface ContextMenuItem<T extends string = string> {...@@ -57,6 +57,7 @@ export interface ContextMenuItem<T extends string = string> {
57 id: T;57 id: T;
58 label: string;58 label: string;
59 destructive?: boolean;59 destructive?: boolean;
60 disabled?: boolean;
60}61}
6162
62export type DesktopUpdateStatus =63export type DesktopUpdateStatus =
packages/contracts/src/orchestration.test.ts+64
...@@ -5,6 +5,8 @@ import { Effect, Schema } from "effect";...@@ -5,6 +5,8 @@ import { Effect, Schema } from "effect";
5import {5import {
6 DEFAULT_PROVIDER_INTERACTION_MODE,6 DEFAULT_PROVIDER_INTERACTION_MODE,
7 DEFAULT_RUNTIME_MODE,7 DEFAULT_RUNTIME_MODE,
8 OrchestrationCommand,
9 OrchestrationEvent,
8 OrchestrationGetTurnDiffInput,10 OrchestrationGetTurnDiffInput,
9 OrchestrationLatestTurn,11 OrchestrationLatestTurn,
10 ProjectCreatedPayload,12 ProjectCreatedPayload,
...@@ -32,6 +34,8 @@ const decodeOrchestrationLatestTurn = Schema.decodeUnknownEffect(OrchestrationLa...@@ -32,6 +34,8 @@ const decodeOrchestrationLatestTurn = Schema.decodeUnknownEffect(OrchestrationLa
32const decodeOrchestrationProposedPlan = Schema.decodeUnknownEffect(OrchestrationProposedPlan);34const decodeOrchestrationProposedPlan = Schema.decodeUnknownEffect(OrchestrationProposedPlan);
33const decodeOrchestrationSession = Schema.decodeUnknownEffect(OrchestrationSession);35const decodeOrchestrationSession = Schema.decodeUnknownEffect(OrchestrationSession);
34const decodeThreadCreatedPayload = Schema.decodeUnknownEffect(ThreadCreatedPayload);36const decodeThreadCreatedPayload = Schema.decodeUnknownEffect(ThreadCreatedPayload);
37const decodeOrchestrationCommand = Schema.decodeUnknownEffect(OrchestrationCommand);
38const decodeOrchestrationEvent = Schema.decodeUnknownEffect(OrchestrationEvent);
35const decodeThreadMetaUpdatedPayload = Schema.decodeUnknownEffect(ThreadMetaUpdatedPayload);39const decodeThreadMetaUpdatedPayload = Schema.decodeUnknownEffect(ThreadMetaUpdatedPayload);
3640
37it.effect("parses turn diff input when fromTurnCount <= toTurnCount", () =>41it.effect("parses turn diff input when fromTurnCount <= toTurnCount", () =>
...@@ -227,6 +231,66 @@ it.effect("decodes thread.meta-updated payloads with explicit provider", () =>...@@ -227,6 +231,66 @@ it.effect("decodes thread.meta-updated payloads with explicit provider", () =>
227 }),231 }),
228);232);
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
230it.effect("accepts provider-scoped model options in thread.turn.start", () =>294it.effect("accepts provider-scoped model options in thread.turn.start", () =>
231 Effect.gen(function* () {295 Effect.gen(function* () {
232 const parsed = yield* decodeThreadTurnStartCommand({296 const parsed = yield* decodeThreadTurnStartCommand({
packages/contracts/src/orchestration.ts+40
...@@ -283,6 +283,7 @@ export const OrchestrationThread = Schema.Struct({...@@ -283,6 +283,7 @@ export const OrchestrationThread = Schema.Struct({
283 latestTurn: Schema.NullOr(OrchestrationLatestTurn),283 latestTurn: Schema.NullOr(OrchestrationLatestTurn),
284 createdAt: IsoDateTime,284 createdAt: IsoDateTime,
285 updatedAt: IsoDateTime,285 updatedAt: IsoDateTime,
286 archivedAt: Schema.NullOr(IsoDateTime).pipe(Schema.withDecodingDefault(() => null)),
286 deletedAt: Schema.NullOr(IsoDateTime),287 deletedAt: Schema.NullOr(IsoDateTime),
287 messages: Schema.Array(OrchestrationMessage),288 messages: Schema.Array(OrchestrationMessage),
288 proposedPlans: Schema.Array(OrchestrationProposedPlan).pipe(Schema.withDecodingDefault(() => [])),289 proposedPlans: Schema.Array(OrchestrationProposedPlan).pipe(Schema.withDecodingDefault(() => [])),
...@@ -348,6 +349,18 @@ const ThreadDeleteCommand = Schema.Struct({...@@ -348,6 +349,18 @@ const ThreadDeleteCommand = Schema.Struct({
348 threadId: ThreadId,349 threadId: ThreadId,
349});350});
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
351const ThreadMetaUpdateCommand = Schema.Struct({364const ThreadMetaUpdateCommand = Schema.Struct({
352 type: Schema.Literal("thread.meta.update"),365 type: Schema.Literal("thread.meta.update"),
353 commandId: CommandId,366 commandId: CommandId,
...@@ -457,6 +470,8 @@ const DispatchableClientOrchestrationCommand = Schema.Union([...@@ -457,6 +470,8 @@ const DispatchableClientOrchestrationCommand = Schema.Union([
457 ProjectDeleteCommand,470 ProjectDeleteCommand,
458 ThreadCreateCommand,471 ThreadCreateCommand,
459 ThreadDeleteCommand,472 ThreadDeleteCommand,
473 ThreadArchiveCommand,
474 ThreadUnarchiveCommand,
460 ThreadMetaUpdateCommand,475 ThreadMetaUpdateCommand,
461 ThreadRuntimeModeSetCommand,476 ThreadRuntimeModeSetCommand,
462 ThreadInteractionModeSetCommand,477 ThreadInteractionModeSetCommand,
...@@ -476,6 +491,8 @@ export const ClientOrchestrationCommand = Schema.Union([...@@ -476,6 +491,8 @@ export const ClientOrchestrationCommand = Schema.Union([
476 ProjectDeleteCommand,491 ProjectDeleteCommand,
477 ThreadCreateCommand,492 ThreadCreateCommand,
478 ThreadDeleteCommand,493 ThreadDeleteCommand,
494 ThreadArchiveCommand,
495 ThreadUnarchiveCommand,
479 ThreadMetaUpdateCommand,496 ThreadMetaUpdateCommand,
480 ThreadRuntimeModeSetCommand,497 ThreadRuntimeModeSetCommand,
481 ThreadInteractionModeSetCommand,498 ThreadInteractionModeSetCommand,
...@@ -576,6 +593,8 @@ export const OrchestrationEventType = Schema.Literals([...@@ -576,6 +593,8 @@ export const OrchestrationEventType = Schema.Literals([
576 "project.deleted",593 "project.deleted",
577 "thread.created",594 "thread.created",
578 "thread.deleted",595 "thread.deleted",
596 "thread.archived",
597 "thread.unarchived",
579 "thread.meta-updated",598 "thread.meta-updated",
580 "thread.runtime-mode-set",599 "thread.runtime-mode-set",
581 "thread.interaction-mode-set",600 "thread.interaction-mode-set",
...@@ -642,6 +661,17 @@ export const ThreadDeletedPayload = Schema.Struct({...@@ -642,6 +661,17 @@ export const ThreadDeletedPayload = Schema.Struct({
642 deletedAt: IsoDateTime,661 deletedAt: IsoDateTime,
643});662});
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
645export const ThreadMetaUpdatedPayload = Schema.Struct({675export const ThreadMetaUpdatedPayload = Schema.Struct({
646 threadId: ThreadId,676 threadId: ThreadId,
647 title: Schema.optional(TrimmedNonEmptyString),677 title: Schema.optional(TrimmedNonEmptyString),
...@@ -799,6 +829,16 @@ export const OrchestrationEvent = Schema.Union([...@@ -799,6 +829,16 @@ export const OrchestrationEvent = Schema.Union([
799 type: Schema.Literal("thread.deleted"),829 type: Schema.Literal("thread.deleted"),
800 payload: ThreadDeletedPayload,830 payload: ThreadDeletedPayload,
801 }),831 }),
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 }),
802 Schema.Struct({842 Schema.Struct({
803 ...EventBaseFields,843 ...EventBaseFields,
804 type: Schema.Literal("thread.meta-updated"),844 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;...@@ -24,6 +24,7 @@ export type SidebarThreadSortOrder = typeof SidebarThreadSortOrder.Type;
24export const DEFAULT_SIDEBAR_THREAD_SORT_ORDER: SidebarThreadSortOrder = "updated_at";24export const DEFAULT_SIDEBAR_THREAD_SORT_ORDER: SidebarThreadSortOrder = "updated_at";
2525
26export const ClientSettingsSchema = Schema.Struct({26export const ClientSettingsSchema = Schema.Struct({
27 confirmThreadArchive: Schema.Boolean.pipe(Schema.withDecodingDefault(() => false)),
27 confirmThreadDelete: Schema.Boolean.pipe(Schema.withDecodingDefault(() => true)),28 confirmThreadDelete: Schema.Boolean.pipe(Schema.withDecodingDefault(() => true)),
28 diffWordWrap: Schema.Boolean.pipe(Schema.withDecodingDefault(() => false)),29 diffWordWrap: Schema.Boolean.pipe(Schema.withDecodingDefault(() => false)),
29 sidebarProjectSortOrder: SidebarProjectSortOrder.pipe(30 sidebarProjectSortOrder: SidebarProjectSortOrder.pipe(