| author | |
| committer | |
| log | f0cdaf94c2b69a0f6a55f7ca2e3d13782b4272a7 |
| tree | ba0443dd8f86b61b91979b0c5f292e9d2d26d700 |
| parent | 48196e67a73f1f8ed8949254306f3cf41e4680bb |
| signature | Signed by PGP key B5690EEEBB952194 |
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 | }); |
| 122 | 122 | ||
| 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; |
| 434 | 435 | ||
| 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_threads | 179 | FROM projection_threads |
| 179 | ORDER BY created_at ASC, thread_id ASC | 180 | 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 |
| 26 | export const ProjectDeletedPayload = ContractsProjectDeletedPayloadSchema; | 28 | export const ProjectDeletedPayload = ContractsProjectDeletedPayloadSchema; |
| 27 | 29 | ||
| 28 | export const ThreadCreatedPayload = ContractsThreadCreatedPayloadSchema; | 30 | export const ThreadCreatedPayload = ContractsThreadCreatedPayloadSchema; |
| 31 | export const ThreadArchivedPayload = ContractsThreadArchivedPayloadSchema; | ||
| 29 | export const ThreadMetaUpdatedPayload = ContractsThreadMetaUpdatedPayloadSchema; | 32 | export const ThreadMetaUpdatedPayload = ContractsThreadMetaUpdatedPayloadSchema; |
| 30 | export const ThreadRuntimeModeSetPayload = ContractsThreadRuntimeModeSetPayloadSchema; | 33 | export const ThreadRuntimeModeSetPayload = ContractsThreadRuntimeModeSetPayloadSchema; |
| 31 | export const ThreadInteractionModeSetPayload = ContractsThreadInteractionModeSetPayloadSchema; | 34 | export const ThreadInteractionModeSetPayload = ContractsThreadInteractionModeSetPayloadSchema; |
| 32 | export const ThreadDeletedPayload = ContractsThreadDeletedPayloadSchema; | 35 | export const ThreadDeletedPayload = ContractsThreadDeletedPayloadSchema; |
| 36 | export const ThreadUnarchivedPayload = ContractsThreadUnarchivedPayloadSchema; | ||
| 33 | 37 | ||
| 34 | export const MessageSentPayloadSchema = ContractsThreadMessageSentPayloadSchema; | 38 | export const MessageSentPayloadSchema = ContractsThreadMessageSentPayloadSchema; |
| 35 | export const ThreadProposedPlanUpsertedPayload = ContractsThreadProposedPlanUpsertedPayloadSchema; | 39 | export 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 | } |
| 90 | 90 | ||
| 91 | export 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 | |||
| 110 | export 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 | |||
| 91 | export function requireThreadAbsent(input: { | 129 | export 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"; |
| 15 | 17 | ||
| 16 | const nowIso = () => new Date().toISOString(); | 18 | const 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 | } |
| 192 | 194 | ||
| 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 | }); |
| 133 | 134 | ||
| 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 | ); |
| 291 | 294 | ||
| 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 | }); |
| 92 | 93 |
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_at | 43 | 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_at | 73 | 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_threads | 96 | 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_threads | 120 | 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"; |
| 29 | import Migration0014 from "./Migrations/014_ProjectionThreadProposedPlanImplementation.ts"; | 29 | import Migration0014 from "./Migrations/014_ProjectionThreadProposedPlanImplementation.ts"; |
| 30 | import Migration0015 from "./Migrations/015_ProjectionTurnsSourceProposedPlan.ts"; | 30 | import Migration0015 from "./Migrations/015_ProjectionTurnsSourceProposedPlan.ts"; |
| 31 | import Migration0016 from "./Migrations/016_CanonicalizeModelSelections.ts"; | 31 | import Migration0016 from "./Migrations/016_CanonicalizeModelSelections.ts"; |
| 32 | import Migration0017 from "./Migrations/017_ProjectionThreadsArchivedAt.ts"; | ||
| 33 | import Migration0018 from "./Migrations/018_ProjectionThreadsArchivedAtIndex.ts"; | ||
| 32 | 34 | ||
| 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; |
| 61 | 65 | ||
| 62 | export const makeMigrationLoader = (throughId?: number) => | 66 | export 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 | }); |
| 285 | 299 | ||
| 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 | }); |
| 305 | 329 | ||
| 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 | }); |
| 324 | 348 | ||
| 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 | }); |
| 338 | 362 | ||
| 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 @@ | ||
| 1 | import * as Effect from "effect/Effect"; | ||
| 2 | import * as SqlClient from "effect/unstable/sql/SqlClient"; | ||
| 3 | |||
| 4 | export 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 @@ | ||
| 1 | import * as Effect from "effect/Effect"; | ||
| 2 | import * as SqlClient from "effect/unstable/sql/SqlClient"; | ||
| 3 | |||
| 4 | export 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 | }); |
| 37 | export type ProjectionThread = typeof ProjectionThread.Type; | 38 | export 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 | } |
| 680 | 680 |
apps/web/src/components/AppSidebarLayout.tsx created+49| ... | @@ -0,0 +1,49 @@ | ||
| 1 | import { useEffect, type ReactNode } from "react"; | ||
| 2 | import { useNavigate } from "@tanstack/react-router"; | ||
| 3 | |||
| 4 | import ThreadSidebar from "./Sidebar"; | ||
| 5 | import { Sidebar, SidebarProvider, SidebarRail } from "./ui/sidebar"; | ||
| 6 | |||
| 7 | const THREAD_SIDEBAR_WIDTH_STORAGE_KEY = "chat_thread_sidebar_width"; | ||
| 8 | const THREAD_SIDEBAR_MIN_WIDTH = 13 * 16; | ||
| 9 | const THREAD_MAIN_CONTENT_MIN_WIDTH = 40 * 16; | ||
| 10 | |||
| 11 | export 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 @@ | ||
| 1 | import { FolderIcon } from "lucide-react"; | ||
| 2 | import { useState } from "react"; | ||
| 3 | |||
| 4 | function 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 | |||
| 22 | const serverHttpOrigin = getServerHttpOrigin(); | ||
| 23 | |||
| 24 | const loadedProjectFaviconSrcs = new Set<string>(); | ||
| 25 | |||
| 26 | export 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 | }); |
| 699 | 700 | ||
| 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 @@ |
| 1 | import { | 1 | import { |
| 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"; |
| 14 | import { ProjectFavicon } from "./ProjectFavicon"; | ||
| 14 | import { autoAnimate } from "@formkit/auto-animate"; | 15 | import { autoAnimate } from "@formkit/auto-animate"; |
| 15 | import { useCallback, useEffect, useMemo, useRef, useState, type MouseEvent } from "react"; | 16 | import { useCallback, useEffect, useMemo, useRef, useState, type MouseEvent } from "react"; |
| 16 | import { | 17 | import { |
| ... | @@ -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"; |
| 39 | import { useMutation, useQueries, useQuery, useQueryClient } from "@tanstack/react-query"; | 40 | import { useQueries, useQuery } from "@tanstack/react-query"; |
| 40 | import { useLocation, useNavigate, useParams } from "@tanstack/react-router"; | 41 | import { useLocation, useNavigate, useParams } from "@tanstack/react-router"; |
| 41 | import { | 42 | import { |
| 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 |
| 48 | import { useStore } from "../store"; | 49 | import { useStore } from "../store"; |
| 49 | import { shortcutLabelForCommand } from "../keybindings"; | 50 | import { shortcutLabelForCommand } from "../keybindings"; |
| 50 | import { derivePendingApprovals, derivePendingUserInputs } from "../session-logic"; | 51 | import { derivePendingApprovals, derivePendingUserInputs } from "../session-logic"; |
| 51 | import { gitRemoveWorktreeMutationOptions, gitStatusQueryOptions } from "../lib/gitReactQuery"; | 52 | import { gitStatusQueryOptions } from "../lib/gitReactQuery"; |
| 52 | import { serverConfigQueryOptions } from "../lib/serverReactQuery"; | 53 | import { serverConfigQueryOptions } from "../lib/serverReactQuery"; |
| 53 | import { readNativeApi } from "../nativeApi"; | 54 | import { readNativeApi } from "../nativeApi"; |
| 54 | import { useComposerDraftStore } from "../composerDraftStore"; | 55 | import { useComposerDraftStore } from "../composerDraftStore"; |
| 55 | import { useHandleNewThread } from "../hooks/useHandleNewThread"; | 56 | import { useHandleNewThread } from "../hooks/useHandleNewThread"; |
| 57 | import { useThreadActions } from "../hooks/useThreadActions"; | ||
| 56 | import { selectThreadTerminalState, useTerminalStateStore } from "../terminalStateStore"; | 58 | import { selectThreadTerminalState, useTerminalStateStore } from "../terminalStateStore"; |
| 57 | import { toastManager } from "./ui/toast"; | 59 | import { toastManager } from "./ui/toast"; |
| 60 | import { formatRelativeTimeLabel } from "../timestampFormat"; | ||
| 61 | import { SettingsSidebarNav } from "./settings/SettingsSidebarNav"; | ||
| 58 | import { | 62 | import { |
| 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"; |
| 89 | import { useThreadSelectionStore } from "../threadSelectionStore"; | 93 | import { useThreadSelectionStore } from "../threadSelectionStore"; |
| 90 | import { formatWorktreePathForDisplay, getOrphanedWorktreePathForThread } from "../worktreeCleanup"; | ||
| 91 | import { isNonEmpty as isNonEmptyString } from "effect/String"; | 94 | import { isNonEmpty as isNonEmptyString } from "effect/String"; |
| 92 | import { | 95 | import { |
| 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; |
| 121 | const loadedProjectFaviconSrcs = new Set<string>(); | ||
| 122 | |||
| 123 | function 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 | |||
| 133 | interface TerminalStatusIndicator { | 123 | interface 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 | } |
| 206 | 196 | ||
| 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 | */ | ||
| 211 | function 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 | |||
| 229 | const serverHttpOrigin = getServerHttpOrigin(); | ||
| 230 | |||
| 231 | function 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 | |||
| 255 | type SortableProjectHandleProps = Pick< | 197 | type 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 | }, []); |
| 495 | 435 | ||
| 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() { |
| 627 | 582 | ||
| 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 | ); |
| 661 | 619 | ||
| 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 | }, []); |
| 1100 | 940 | ||
| 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"; |
| 1106 | 950 | ||
| ... | @@ -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 | ); |
| 1152 | 1000 | ||
| 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 | <SidebarMenuSubButton | 1014 | <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 | <span | 1136 | <div className="flex min-w-12 justify-end"> |
| 1277 | className={`text-[10px] ${ | 1137 | {confirmingArchiveThreadId === thread.id && !isThreadRunning ? ( |
| 1278 | isHighlighted | 1138 | <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 | showOnHover | 1282 | 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 | )} |
| 1660 | 1591 | ||
| 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 | <Button | 1601 | <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 | Projects | 1619 | ) : 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 | <ProjectSortMenu | 1622 | <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 | <TooltipTrigger | 1633 | 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 | <button | 1666 | <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 | <PlusIcon | 1701 | className="shrink-0 rounded-md bg-primary px-2.5 py-1 text-xs font-medium text-primary-foreground transition-colors duration-150 hover:bg-primary/90 disabled:opacity-60" |
| 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 | )} | ||
| 1725 | 1727 | ||
| 1726 | {shouldShowProjectPathEntry && ( | 1728 | {isManualProjectSorting ? ( |
| 1727 | <div className="mb-2 px-1"> | 1729 | <DndContext |
| 1728 | {isElectron && ( | 1730 | sensors={projectDnDSensors} |
| 1729 | <button | 1731 | 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 | <input | 1760 | {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 | addProjectError | 1763 | </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 | <button | 1766 | </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 | Cancel | 1774 | 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> |
| 1822 | 1783 | </> | |
| 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 @@ | ||
| 1 | import { | ||
| 2 | ArchiveIcon, | ||
| 3 | ArchiveX, | ||
| 4 | ChevronDownIcon, | ||
| 5 | InfoIcon, | ||
| 6 | LoaderIcon, | ||
| 7 | PlusIcon, | ||
| 8 | RefreshCwIcon, | ||
| 9 | Undo2Icon, | ||
| 10 | XIcon, | ||
| 11 | } from "lucide-react"; | ||
| 12 | import { useQuery, useQueryClient } from "@tanstack/react-query"; | ||
| 13 | import { type ReactNode, useCallback, useEffect, useMemo, useRef, useState } from "react"; | ||
| 14 | import { | ||
| 15 | PROVIDER_DISPLAY_NAMES, | ||
| 16 | type ProviderKind, | ||
| 17 | type ServerProvider, | ||
| 18 | type ServerProviderModel, | ||
| 19 | ThreadId, | ||
| 20 | } from "@t3tools/contracts"; | ||
| 21 | import { DEFAULT_UNIFIED_SETTINGS } from "@t3tools/contracts/settings"; | ||
| 22 | import { normalizeModelSlug } from "@t3tools/shared/model"; | ||
| 23 | import { Equal } from "effect"; | ||
| 24 | import { APP_VERSION } from "../../branding"; | ||
| 25 | import { ProviderModelPicker } from "../chat/ProviderModelPicker"; | ||
| 26 | import { TraitsPicker } from "../chat/TraitsPicker"; | ||
| 27 | import { resolveAndPersistPreferredEditor } from "../../editorPreferences"; | ||
| 28 | import { useTheme } from "../../hooks/useTheme"; | ||
| 29 | import { useSettings, useUpdateSettings } from "../../hooks/useSettings"; | ||
| 30 | import { useThreadActions } from "../../hooks/useThreadActions"; | ||
| 31 | import { serverConfigQueryOptions, serverQueryKeys } from "../../lib/serverReactQuery"; | ||
| 32 | import { | ||
| 33 | MAX_CUSTOM_MODEL_LENGTH, | ||
| 34 | getCustomModelOptionsByProvider, | ||
| 35 | resolveAppModelSelectionState, | ||
| 36 | } from "../../modelSelection"; | ||
| 37 | import { ensureNativeApi, readNativeApi } from "../../nativeApi"; | ||
| 38 | import { useStore } from "../../store"; | ||
| 39 | import { formatRelativeTime, formatRelativeTimeLabel } from "../../timestampFormat"; | ||
| 40 | import { cn } from "../../lib/utils"; | ||
| 41 | import { Button } from "../ui/button"; | ||
| 42 | import { Collapsible, CollapsibleContent } from "../ui/collapsible"; | ||
| 43 | import { Empty, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle } from "../ui/empty"; | ||
| 44 | import { Input } from "../ui/input"; | ||
| 45 | import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "../ui/select"; | ||
| 46 | import { Switch } from "../ui/switch"; | ||
| 47 | import { toastManager } from "../ui/toast"; | ||
| 48 | import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; | ||
| 49 | import { ProjectFavicon } from "../ProjectFavicon"; | ||
| 50 | |||
| 51 | const 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 | |||
| 66 | const TIMESTAMP_FORMAT_LABELS = { | ||
| 67 | locale: "System default", | ||
| 68 | "12-hour": "12-hour", | ||
| 69 | "24-hour": "24-hour", | ||
| 70 | } as const; | ||
| 71 | |||
| 72 | const EMPTY_SERVER_PROVIDERS: ReadonlyArray<ServerProvider> = []; | ||
| 73 | |||
| 74 | type InstallProviderSettings = { | ||
| 75 | provider: ProviderKind; | ||
| 76 | title: string; | ||
| 77 | binaryPlaceholder: string; | ||
| 78 | binaryDescription: ReactNode; | ||
| 79 | homePathKey?: "codexHomePath"; | ||
| 80 | homePlaceholder?: string; | ||
| 81 | homeDescription?: ReactNode; | ||
| 82 | }; | ||
| 83 | |||
| 84 | const 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 | |||
| 102 | const 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 | |||
| 117 | function 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 | |||
| 168 | function getProviderVersionLabel(version: string | null | undefined) { | ||
| 169 | if (!version) return null; | ||
| 170 | return version.startsWith("v") ? version : `v${version}`; | ||
| 171 | } | ||
| 172 | |||
| 173 | function 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 | |||
| 182 | function 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 | |||
| 204 | function 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 | |||
| 231 | function 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 | |||
| 270 | function 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 | |||
| 294 | function 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 | |||
| 302 | export 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 | |||
| 375 | export 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 | |||
| 1272 | export 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 @@ | ||
| 1 | import type { ComponentType } from "react"; | ||
| 2 | import { ArchiveIcon, ArrowLeftIcon, Settings2Icon } from "lucide-react"; | ||
| 3 | import { useNavigate } from "@tanstack/react-router"; | ||
| 4 | |||
| 5 | import { | ||
| 6 | SidebarContent, | ||
| 7 | SidebarFooter, | ||
| 8 | SidebarGroup, | ||
| 9 | SidebarMenu, | ||
| 10 | SidebarMenuButton, | ||
| 11 | SidebarMenuItem, | ||
| 12 | SidebarSeparator, | ||
| 13 | } from "../ui/sidebar"; | ||
| 14 | |||
| 15 | export type SettingsSectionPath = "/settings/general" | "/settings/archived"; | ||
| 16 | |||
| 17 | export 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 | |||
| 26 | export 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 = isDestructiveAction | 47 | 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 | } |
| 53 | 59 |
apps/web/src/hooks/useSettings.test.ts created+16| ... | @@ -0,0 +1,16 @@ | ||
| 1 | import { describe, expect, it } from "vitest"; | ||
| 2 | import { buildLegacyClientSettingsMigrationPatch } from "./useSettings"; | ||
| 3 | |||
| 4 | describe("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>> = {}; |
| 204 | 204 | ||
| 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 @@ | ||
| 1 | import { ThreadId } from "@t3tools/contracts"; | ||
| 2 | import { useMutation, useQueryClient } from "@tanstack/react-query"; | ||
| 3 | import { useNavigate, useParams } from "@tanstack/react-router"; | ||
| 4 | import { useCallback } from "react"; | ||
| 5 | |||
| 6 | import { getFallbackThreadIdAfterDelete } from "../components/Sidebar.logic"; | ||
| 7 | import { useComposerDraftStore } from "../composerDraftStore"; | ||
| 8 | import { useHandleNewThread } from "./useHandleNewThread"; | ||
| 9 | import { gitRemoveWorktreeMutationOptions } from "../lib/gitReactQuery"; | ||
| 10 | import { newCommandId } from "../lib/utils"; | ||
| 11 | import { readNativeApi } from "../nativeApi"; | ||
| 12 | import { useStore } from "../store"; | ||
| 13 | import { useTerminalStateStore } from "../terminalStateStore"; | ||
| 14 | import { formatWorktreePathForDisplay, getOrphanedWorktreePathForThread } from "../worktreeCleanup"; | ||
| 15 | import { toastManager } from "../components/ui/toast"; | ||
| 16 | import { useSettings } from "./useSettings"; | ||
| 17 | |||
| 18 | export 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 | } |
| 25 | 25 | ||
| 26 | export 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 | |||
| 38 | export function resolveStorage(storage: Partial<StateStorage> | null | undefined): StateStorage { | ||
| 39 | return isStateStorage(storage) ? storage : createMemoryStorage(); | ||
| 40 | } | ||
| 41 | |||
| 26 | export function createDebouncedStorage( | 42 | export 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 | ); |
| 36 | 53 | ||
| 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. |
| 10 | 10 | ||
| 11 | import { Route as rootRouteImport } from './routes/__root' | 11 | import { Route as rootRouteImport } from './routes/__root' |
| 12 | import { Route as SettingsRouteImport } from './routes/settings' | ||
| 12 | import { Route as ChatRouteImport } from './routes/_chat' | 13 | import { Route as ChatRouteImport } from './routes/_chat' |
| 13 | import { Route as ChatIndexRouteImport } from './routes/_chat.index' | 14 | import { Route as ChatIndexRouteImport } from './routes/_chat.index' |
| 14 | import { Route as ChatSettingsRouteImport } from './routes/_chat.settings' | 15 | import { Route as SettingsGeneralRouteImport } from './routes/settings.general' |
| 16 | import { Route as SettingsArchivedRouteImport } from './routes/settings.archived' | ||
| 15 | import { Route as ChatThreadIdRouteImport } from './routes/_chat.$threadId' | 17 | import { Route as ChatThreadIdRouteImport } from './routes/_chat.$threadId' |
| 16 | 18 | ||
| 19 | const SettingsRoute = SettingsRouteImport.update({ | ||
| 20 | id: '/settings', | ||
| 21 | path: '/settings', | ||
| 22 | getParentRoute: () => rootRouteImport, | ||
| 23 | } as any) | ||
| 17 | const ChatRoute = ChatRouteImport.update({ | 24 | const 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) |
| 26 | const ChatSettingsRoute = ChatSettingsRouteImport.update({ | 33 | const SettingsGeneralRoute = SettingsGeneralRouteImport.update({ |
| 27 | id: '/settings', | 34 | id: '/general', |
| 28 | path: '/settings', | 35 | path: '/general', |
| 29 | getParentRoute: () => ChatRoute, | 36 | getParentRoute: () => SettingsRoute, |
| 37 | } as any) | ||
| 38 | const SettingsArchivedRoute = SettingsArchivedRouteImport.update({ | ||
| 39 | id: '/archived', | ||
| 40 | path: '/archived', | ||
| 41 | getParentRoute: () => SettingsRoute, | ||
| 30 | } as any) | 42 | } as any) |
| 31 | const ChatThreadIdRoute = ChatThreadIdRouteImport.update({ | 43 | const 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({ |
| 36 | 48 | ||
| 37 | export interface FileRoutesByFullPath { | 49 | export interface FileRoutesByFullPath { |
| 38 | '/': typeof ChatIndexRoute | 50 | '/': typeof ChatIndexRoute |
| 51 | '/settings': typeof SettingsRouteWithChildren | ||
| 39 | '/$threadId': typeof ChatThreadIdRoute | 52 | '/$threadId': typeof ChatThreadIdRoute |
| 40 | '/settings': typeof ChatSettingsRoute | 53 | '/settings/archived': typeof SettingsArchivedRoute |
| 54 | '/settings/general': typeof SettingsGeneralRoute | ||
| 41 | } | 55 | } |
| 42 | export interface FileRoutesByTo { | 56 | export interface FileRoutesByTo { |
| 57 | '/settings': typeof SettingsRouteWithChildren | ||
| 43 | '/$threadId': typeof ChatThreadIdRoute | 58 | '/$threadId': typeof ChatThreadIdRoute |
| 44 | '/settings': typeof ChatSettingsRoute | 59 | '/settings/archived': typeof SettingsArchivedRoute |
| 60 | '/settings/general': typeof SettingsGeneralRoute | ||
| 45 | '/': typeof ChatIndexRoute | 61 | '/': typeof ChatIndexRoute |
| 46 | } | 62 | } |
| 47 | export interface FileRoutesById { | 63 | export interface FileRoutesById { |
| 48 | __root__: typeof rootRouteImport | 64 | __root__: typeof rootRouteImport |
| 49 | '/_chat': typeof ChatRouteWithChildren | 65 | '/_chat': typeof ChatRouteWithChildren |
| 66 | '/settings': typeof SettingsRouteWithChildren | ||
| 50 | '/_chat/$threadId': typeof ChatThreadIdRoute | 67 | '/_chat/$threadId': typeof ChatThreadIdRoute |
| 51 | '/_chat/settings': typeof ChatSettingsRoute | 68 | '/settings/archived': typeof SettingsArchivedRoute |
| 69 | '/settings/general': typeof SettingsGeneralRoute | ||
| 52 | '/_chat/': typeof ChatIndexRoute | 70 | '/_chat/': typeof ChatIndexRoute |
| 53 | } | 71 | } |
| 54 | export interface FileRouteTypes { | 72 | export interface FileRouteTypes { |
| 55 | fileRoutesByFullPath: FileRoutesByFullPath | 73 | fileRoutesByFullPath: FileRoutesByFullPath |
| 56 | fullPaths: '/' | '/$threadId' | '/settings' | 74 | fullPaths: |
| 75 | | '/' | ||
| 76 | | '/settings' | ||
| 77 | | '/$threadId' | ||
| 78 | | '/settings/archived' | ||
| 79 | | '/settings/general' | ||
| 57 | fileRoutesByTo: FileRoutesByTo | 80 | 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: FileRoutesById | 95 | fileRoutesById: FileRoutesById |
| 61 | } | 96 | } |
| 62 | export interface RootRouteChildren { | 97 | export interface RootRouteChildren { |
| 63 | ChatRoute: typeof ChatRouteWithChildren | 98 | ChatRoute: typeof ChatRouteWithChildren |
| 99 | SettingsRoute: typeof SettingsRouteWithChildren | ||
| 64 | } | 100 | } |
| 65 | 101 | ||
| 66 | declare module '@tanstack/react-router' { | 102 | declare 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 ChatIndexRouteImport | 122 | preLoaderRoute: typeof ChatIndexRouteImport |
| 80 | parentRoute: typeof ChatRoute | 123 | 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 ChatSettingsRouteImport | 129 | preLoaderRoute: typeof SettingsGeneralRouteImport |
| 87 | parentRoute: typeof ChatRoute | 130 | parentRoute: typeof SettingsRoute |
| 131 | } | ||
| 132 | '/settings/archived': { | ||
| 133 | id: '/settings/archived' | ||
| 134 | path: '/archived' | ||
| 135 | fullPath: '/settings/archived' | ||
| 136 | preLoaderRoute: typeof SettingsArchivedRouteImport | ||
| 137 | parentRoute: typeof SettingsRoute | ||
| 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' { |
| 98 | 148 | ||
| 99 | interface ChatRouteChildren { | 149 | interface ChatRouteChildren { |
| 100 | ChatThreadIdRoute: typeof ChatThreadIdRoute | 150 | ChatThreadIdRoute: typeof ChatThreadIdRoute |
| 101 | ChatSettingsRoute: typeof ChatSettingsRoute | ||
| 102 | ChatIndexRoute: typeof ChatIndexRoute | 151 | ChatIndexRoute: typeof ChatIndexRoute |
| 103 | } | 152 | } |
| 104 | 153 | ||
| 105 | const ChatRouteChildren: ChatRouteChildren = { | 154 | const ChatRouteChildren: ChatRouteChildren = { |
| 106 | ChatThreadIdRoute: ChatThreadIdRoute, | 155 | ChatThreadIdRoute: ChatThreadIdRoute, |
| 107 | ChatSettingsRoute: ChatSettingsRoute, | ||
| 108 | ChatIndexRoute: ChatIndexRoute, | 156 | ChatIndexRoute: ChatIndexRoute, |
| 109 | } | 157 | } |
| 110 | 158 | ||
| 111 | const ChatRouteWithChildren = ChatRoute._addFileChildren(ChatRouteChildren) | 159 | const ChatRouteWithChildren = ChatRoute._addFileChildren(ChatRouteChildren) |
| 112 | 160 | ||
| 161 | interface SettingsRouteChildren { | ||
| 162 | SettingsArchivedRoute: typeof SettingsArchivedRoute | ||
| 163 | SettingsGeneralRoute: typeof SettingsGeneralRoute | ||
| 164 | } | ||
| 165 | |||
| 166 | const SettingsRouteChildren: SettingsRouteChildren = { | ||
| 167 | SettingsArchivedRoute: SettingsArchivedRoute, | ||
| 168 | SettingsGeneralRoute: SettingsGeneralRoute, | ||
| 169 | } | ||
| 170 | |||
| 171 | const SettingsRouteWithChildren = SettingsRoute._addFileChildren( | ||
| 172 | SettingsRouteChildren, | ||
| 173 | ) | ||
| 174 | |||
| 113 | const rootRouteChildren: RootRouteChildren = { | 175 | const rootRouteChildren: RootRouteChildren = { |
| 114 | ChatRoute: ChatRouteWithChildren, | 176 | ChatRoute: ChatRouteWithChildren, |
| 177 | SettingsRoute: SettingsRouteWithChildren, | ||
| 115 | } | 178 | } |
| 116 | export const routeTree = rootRouteImport | 179 | export 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"; |
| 11 | import { Throttler } from "@tanstack/react-pacer"; | 11 | import { Throttler } from "@tanstack/react-pacer"; |
| 12 | 12 | ||
| 13 | import { APP_DISPLAY_NAME } from "../branding"; | 13 | import { APP_DISPLAY_NAME } from "../branding"; |
| 14 | import { AppSidebarLayout } from "../components/AppSidebarLayout"; | ||
| 14 | import { Button } from "../components/ui/button"; | 15 | import { Button } from "../components/ui/button"; |
| 15 | import { AnchoredToastProvider, ToastProvider, toastManager } from "../components/ui/toast"; | 16 | import { AnchoredToastProvider, ToastProvider, toastManager } from "../components/ui/toast"; |
| 16 | import { resolveAndPersistPreferredEditor } from "../editorPreferences"; | 17 | import { 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 @@ | ||
| 1 | import { createFileRoute } from "@tanstack/react-router"; | ||
| 2 | import { useQuery, useQueryClient } from "@tanstack/react-query"; | ||
| 3 | import { | ||
| 4 | ChevronDownIcon, | ||
| 5 | InfoIcon, | ||
| 6 | LoaderIcon, | ||
| 7 | PlusIcon, | ||
| 8 | RefreshCwIcon, | ||
| 9 | RotateCcwIcon, | ||
| 10 | Undo2Icon, | ||
| 11 | XIcon, | ||
| 12 | } from "lucide-react"; | ||
| 13 | import { type ReactNode, useCallback, useEffect, useRef, useState } from "react"; | ||
| 14 | import { | ||
| 15 | PROVIDER_DISPLAY_NAMES, | ||
| 16 | type ProviderKind, | ||
| 17 | type ServerProvider, | ||
| 18 | type ServerProviderModel, | ||
| 19 | } from "@t3tools/contracts"; | ||
| 20 | import { normalizeModelSlug } from "@t3tools/shared/model"; | ||
| 21 | import { useSettings, useUpdateSettings } from "../hooks/useSettings"; | ||
| 22 | import { | ||
| 23 | getCustomModelOptionsByProvider, | ||
| 24 | MAX_CUSTOM_MODEL_LENGTH, | ||
| 25 | resolveAppModelSelectionState, | ||
| 26 | } from "../modelSelection"; | ||
| 27 | import { APP_VERSION } from "../branding"; | ||
| 28 | import { Button } from "../components/ui/button"; | ||
| 29 | import { Collapsible, CollapsibleContent } from "../components/ui/collapsible"; | ||
| 30 | import { Input } from "../components/ui/input"; | ||
| 31 | import { | ||
| 32 | Select, | ||
| 33 | SelectItem, | ||
| 34 | SelectPopup, | ||
| 35 | SelectTrigger, | ||
| 36 | SelectValue, | ||
| 37 | } from "../components/ui/select"; | ||
| 38 | import { SidebarTrigger } from "../components/ui/sidebar"; | ||
| 39 | import { Switch } from "../components/ui/switch"; | ||
| 40 | import { ProviderModelPicker } from "../components/chat/ProviderModelPicker"; | ||
| 41 | import { TraitsPicker } from "../components/chat/TraitsPicker"; | ||
| 42 | import { SidebarInset } from "../components/ui/sidebar"; | ||
| 43 | import { Tooltip, TooltipPopup, TooltipTrigger } from "../components/ui/tooltip"; | ||
| 44 | import { resolveAndPersistPreferredEditor } from "../editorPreferences"; | ||
| 45 | import { isElectron } from "../env"; | ||
| 46 | import { useTheme } from "../hooks/useTheme"; | ||
| 47 | import { serverConfigQueryOptions, serverQueryKeys } from "../lib/serverReactQuery"; | ||
| 48 | import { cn } from "../lib/utils"; | ||
| 49 | import { formatRelativeTime } from "../timestampFormat"; | ||
| 50 | import { ensureNativeApi, readNativeApi } from "../nativeApi"; | ||
| 51 | import { DEFAULT_UNIFIED_SETTINGS } from "@t3tools/contracts/settings"; | ||
| 52 | import { Equal } from "effect"; | ||
| 53 | |||
| 54 | const 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 | |||
| 72 | const TIMESTAMP_FORMAT_LABELS = { | ||
| 73 | locale: "System default", | ||
| 74 | "12-hour": "12-hour", | ||
| 75 | "24-hour": "24-hour", | ||
| 76 | } as const; | ||
| 77 | |||
| 78 | const EMPTY_SERVER_PROVIDERS: ReadonlyArray<ServerProvider> = []; | ||
| 79 | |||
| 80 | type InstallProviderSettings = { | ||
| 81 | provider: ProviderKind; | ||
| 82 | title: string; | ||
| 83 | binaryPlaceholder: string; | ||
| 84 | binaryDescription: ReactNode; | ||
| 85 | homePathKey?: "codexHomePath"; | ||
| 86 | homePlaceholder?: string; | ||
| 87 | homeDescription?: ReactNode; | ||
| 88 | }; | ||
| 89 | |||
| 90 | const 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 | |||
| 108 | const 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 | |||
| 127 | function 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 | |||
| 181 | function 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. */ | ||
| 187 | function 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 | |||
| 196 | function 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 | |||
| 220 | function 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 | |||
| 262 | function 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 | |||
| 286 | function 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 | |||
| 1311 | export 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 @@ |
| 1 | import { type ResolvedKeybindingsConfig } from "@t3tools/contracts"; | 1 | import { type ResolvedKeybindingsConfig } from "@t3tools/contracts"; |
| 2 | import { useQuery } from "@tanstack/react-query"; | 2 | import { useQuery } from "@tanstack/react-query"; |
| 3 | import { Outlet, createFileRoute, useNavigate } from "@tanstack/react-router"; | 3 | import { Outlet, createFileRoute } from "@tanstack/react-router"; |
| 4 | import { useEffect } from "react"; | 4 | import { useEffect } from "react"; |
| 5 | 5 | ||
| 6 | import ThreadSidebar from "../components/Sidebar"; | ||
| 7 | import { useHandleNewThread } from "../hooks/useHandleNewThread"; | 6 | import { useHandleNewThread } from "../hooks/useHandleNewThread"; |
| 8 | import { isTerminalFocused } from "../lib/terminalFocus"; | 7 | import { isTerminalFocused } from "../lib/terminalFocus"; |
| 9 | import { serverConfigQueryOptions } from "../lib/serverReactQuery"; | 8 | import { serverConfigQueryOptions } from "../lib/serverReactQuery"; |
| ... | @@ -12,12 +11,8 @@ import { selectThreadTerminalState, useTerminalStateStore } from "../terminalSta | ... | @@ -12,12 +11,8 @@ import { selectThreadTerminalState, useTerminalStateStore } from "../terminalSta |
| 12 | import { useThreadSelectionStore } from "../threadSelectionStore"; | 11 | import { useThreadSelectionStore } from "../threadSelectionStore"; |
| 13 | import { resolveSidebarNewThreadEnvMode } from "~/components/Sidebar.logic"; | 12 | import { resolveSidebarNewThreadEnvMode } from "~/components/Sidebar.logic"; |
| 14 | import { useSettings } from "~/hooks/useSettings"; | 13 | import { useSettings } from "~/hooks/useSettings"; |
| 15 | import { Sidebar, SidebarProvider, SidebarRail } from "~/components/ui/sidebar"; | ||
| 16 | 14 | ||
| 17 | const EMPTY_KEYBINDINGS: ResolvedKeybindingsConfig = []; | 15 | const EMPTY_KEYBINDINGS: ResolvedKeybindingsConfig = []; |
| 18 | const THREAD_SIDEBAR_WIDTH_STORAGE_KEY = "chat_thread_sidebar_width"; | ||
| 19 | const THREAD_SIDEBAR_MIN_WIDTH = 13 * 16; | ||
| 20 | const THREAD_MAIN_CONTENT_MIN_WIDTH = 40 * 16; | ||
| 21 | 16 | ||
| 22 | function ChatRouteGlobalShortcuts() { | 17 | function 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 | } |
| 95 | 90 | ||
| 96 | function ChatRouteLayout() { | 91 | function 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 | } |
| 136 | 99 |
apps/web/src/routes/settings.archived.tsx created+7| ... | @@ -0,0 +1,7 @@ | ||
| 1 | import { createFileRoute } from "@tanstack/react-router"; | ||
| 2 | |||
| 3 | import { ArchivedThreadsPanel } from "../components/settings/SettingsPanels"; | ||
| 4 | |||
| 5 | export const Route = createFileRoute("/settings/archived")({ | ||
| 6 | component: ArchivedThreadsPanel, | ||
| 7 | }); | ||
apps/web/src/routes/settings.general.tsx created+7| ... | @@ -0,0 +1,7 @@ | ||
| 1 | import { createFileRoute } from "@tanstack/react-router"; | ||
| 2 | |||
| 3 | import { GeneralSettingsPanel } from "../components/settings/SettingsPanels"; | ||
| 4 | |||
| 5 | export const Route = createFileRoute("/settings/general")({ | ||
| 6 | component: GeneralSettingsPanel, | ||
| 7 | }); | ||
apps/web/src/routes/settings.tsx created+77| ... | @@ -0,0 +1,77 @@ | ||
| 1 | import { RotateCcwIcon } from "lucide-react"; | ||
| 2 | import { Outlet, createFileRoute, redirect } from "@tanstack/react-router"; | ||
| 3 | import { useState } from "react"; | ||
| 4 | |||
| 5 | import { useSettingsRestore } from "../components/settings/SettingsPanels"; | ||
| 6 | import { Button } from "../components/ui/button"; | ||
| 7 | import { SidebarInset, SidebarTrigger } from "../components/ui/sidebar"; | ||
| 8 | import { isElectron } from "../env"; | ||
| 9 | |||
| 10 | function 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 | |||
| 66 | function SettingsRouteLayout() { | ||
| 67 | return <SettingsContentLayout />; | ||
| 68 | } | ||
| 69 | |||
| 70 | export 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 | }); |
| 272 | 274 | ||
| 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"); |
| 7 | 7 | ||
| 8 | describe("terminalStateStore actions", () => { | 8 | describe("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 | }); |
| 15 | 13 |
apps/web/src/terminalStateStore.ts+6-1| ... | @@ -8,6 +8,7 @@ | ... | @@ -8,6 +8,7 @@ |
| 8 | import type { ThreadId } from "@t3tools/contracts"; | 8 | import type { ThreadId } from "@t3tools/contracts"; |
| 9 | import { create } from "zustand"; | 9 | import { create } from "zustand"; |
| 10 | import { createJSONStorage, persist } from "zustand/middleware"; | 10 | import { createJSONStorage, persist } from "zustand/middleware"; |
| 11 | import { resolveStorage } from "./lib/storage"; | ||
| 11 | import { | 12 | import { |
| 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 { |
| 27 | 28 | ||
| 28 | const TERMINAL_STATE_STORAGE_KEY = "t3code:terminal-state:v1"; | 29 | const TERMINAL_STATE_STORAGE_KEY = "t3code:terminal-state:v1"; |
| 29 | 30 | ||
| 31 | function createTerminalStateStorage() { | ||
| 32 | return resolveStorage(typeof window !== "undefined" ? window.localStorage : undefined); | ||
| 33 | } | ||
| 34 | |||
| 30 | function normalizeTerminalIds(terminalIds: string[]): string[] { | 35 | function 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 | |||
| 70 | export 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 | } |
| 61 | 62 | ||
| 62 | export type DesktopUpdateStatus = | 63 | export 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"; |
| 5 | import { | 5 | import { |
| 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 |
| 32 | const decodeOrchestrationProposedPlan = Schema.decodeUnknownEffect(OrchestrationProposedPlan); | 34 | const decodeOrchestrationProposedPlan = Schema.decodeUnknownEffect(OrchestrationProposedPlan); |
| 33 | const decodeOrchestrationSession = Schema.decodeUnknownEffect(OrchestrationSession); | 35 | const decodeOrchestrationSession = Schema.decodeUnknownEffect(OrchestrationSession); |
| 34 | const decodeThreadCreatedPayload = Schema.decodeUnknownEffect(ThreadCreatedPayload); | 36 | const decodeThreadCreatedPayload = Schema.decodeUnknownEffect(ThreadCreatedPayload); |
| 37 | const decodeOrchestrationCommand = Schema.decodeUnknownEffect(OrchestrationCommand); | ||
| 38 | const decodeOrchestrationEvent = Schema.decodeUnknownEffect(OrchestrationEvent); | ||
| 35 | const decodeThreadMetaUpdatedPayload = Schema.decodeUnknownEffect(ThreadMetaUpdatedPayload); | 39 | const decodeThreadMetaUpdatedPayload = Schema.decodeUnknownEffect(ThreadMetaUpdatedPayload); |
| 36 | 40 | ||
| 37 | it.effect("parses turn diff input when fromTurnCount <= toTurnCount", () => | 41 | it.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 | ); |
| 229 | 233 | ||
| 234 | it.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 | |||
| 252 | it.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 | |||
| 230 | it.effect("accepts provider-scoped model options in thread.turn.start", () => | 294 | it.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 | }); |
| 350 | 351 | ||
| 352 | const ThreadArchiveCommand = Schema.Struct({ | ||
| 353 | type: Schema.Literal("thread.archive"), | ||
| 354 | commandId: CommandId, | ||
| 355 | threadId: ThreadId, | ||
| 356 | }); | ||
| 357 | |||
| 358 | const ThreadUnarchiveCommand = Schema.Struct({ | ||
| 359 | type: Schema.Literal("thread.unarchive"), | ||
| 360 | commandId: CommandId, | ||
| 361 | threadId: ThreadId, | ||
| 362 | }); | ||
| 363 | |||
| 351 | const ThreadMetaUpdateCommand = Schema.Struct({ | 364 | const 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 | }); |
| 644 | 663 | ||
| 664 | export const ThreadArchivedPayload = Schema.Struct({ | ||
| 665 | threadId: ThreadId, | ||
| 666 | archivedAt: IsoDateTime, | ||
| 667 | updatedAt: IsoDateTime, | ||
| 668 | }); | ||
| 669 | |||
| 670 | export const ThreadUnarchivedPayload = Schema.Struct({ | ||
| 671 | threadId: ThreadId, | ||
| 672 | updatedAt: IsoDateTime, | ||
| 673 | }); | ||
| 674 | |||
| 645 | export const ThreadMetaUpdatedPayload = Schema.Struct({ | 675 | export 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 @@ | ||
| 1 | import { describe, expect, it } from "vitest"; | ||
| 2 | import { DEFAULT_CLIENT_SETTINGS } from "./settings"; | ||
| 3 | |||
| 4 | describe("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; |
| 24 | export const DEFAULT_SIDEBAR_THREAD_SORT_ORDER: SidebarThreadSortOrder = "updated_at"; | 24 | export const DEFAULT_SIDEBAR_THREAD_SORT_ORDER: SidebarThreadSortOrder = "updated_at"; |
| 25 | 25 | ||
| 26 | export const ClientSettingsSchema = Schema.Struct({ | 26 | export 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( |