| 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 | 1141 | id: item.id, |
| 1142 | 1142 | label: item.label, |
| 1143 | 1143 | destructive: item.destructive === true, |
| 1144 | disabled: item.disabled === true, | |
| 1144 | 1145 | })); |
| 1145 | 1146 | if (normalizedItems.length === 0) { |
| 1146 | 1147 | return null; |
| ... | ... | @@ -1171,6 +1172,7 @@ function registerIpcHandlers(): void { |
| 1171 | 1172 | } |
| 1172 | 1173 | const itemOption: MenuItemConstructorOptions = { |
| 1173 | 1174 | label: item.label, |
| 1175 | enabled: !item.disabled, | |
| 1174 | 1176 | click: () => resolve(item.id), |
| 1175 | 1177 | }; |
| 1176 | 1178 | if (item.destructive) { |
apps/server/src/checkpointing/Layers/CheckpointDiffQuery.test.ts+1| ... | ... | @@ -61,6 +61,7 @@ function makeSnapshot(input: { |
| 61 | 61 | }, |
| 62 | 62 | createdAt: "2026-01-01T00:00:00.000Z", |
| 63 | 63 | updatedAt: "2026-01-01T00:00:00.000Z", |
| 64 | archivedAt: null, | |
| 64 | 65 | deletedAt: null, |
| 65 | 66 | messages: [], |
| 66 | 67 | activities: [], |
apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts+67| ... | ... | @@ -120,6 +120,73 @@ describe("OrchestrationEngine", () => { |
| 120 | 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 | 190 | it("replays append-only events from sequence", async () => { |
| 124 | 191 | const system = await createOrchestrationSystem(); |
| 125 | 192 | const { engine } = system; |
apps/server/src/orchestration/Layers/ProjectionPipeline.ts+31| ... | ... | @@ -428,10 +428,41 @@ const makeOrchestrationProjectionPipeline = Effect.gen(function* () { |
| 428 | 428 | latestTurnId: null, |
| 429 | 429 | createdAt: event.payload.createdAt, |
| 430 | 430 | updatedAt: event.payload.updatedAt, |
| 431 | archivedAt: null, | |
| 431 | 432 | deletedAt: null, |
| 432 | 433 | }); |
| 433 | 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 | 466 | case "thread.meta-updated": { |
| 436 | 467 | const existingRow = yield* projectionThreadRepository.getById({ |
| 437 | 468 | threadId: event.payload.threadId, |
apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts+1| ... | ... | @@ -279,6 +279,7 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { |
| 279 | 279 | }, |
| 280 | 280 | createdAt: "2026-02-24T00:00:02.000Z", |
| 281 | 281 | updatedAt: "2026-02-24T00:00:03.000Z", |
| 282 | archivedAt: null, | |
| 282 | 283 | deletedAt: null, |
| 283 | 284 | messages: [ |
| 284 | 285 | { |
apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts+2| ... | ... | @@ -174,6 +174,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { |
| 174 | 174 | latest_turn_id AS "latestTurnId", |
| 175 | 175 | created_at AS "createdAt", |
| 176 | 176 | updated_at AS "updatedAt", |
| 177 | archived_at AS "archivedAt", | |
| 177 | 178 | deleted_at AS "deletedAt" |
| 178 | 179 | FROM projection_threads |
| 179 | 180 | ORDER BY created_at ASC, thread_id ASC |
| ... | ... | @@ -560,6 +561,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { |
| 560 | 561 | latestTurn: latestTurnByThread.get(row.threadId) ?? null, |
| 561 | 562 | createdAt: row.createdAt, |
| 562 | 563 | updatedAt: row.updatedAt, |
| 564 | archivedAt: row.archivedAt, | |
| 563 | 565 | deletedAt: row.deletedAt, |
| 564 | 566 | messages: messagesByThread.get(row.threadId) ?? [], |
| 565 | 567 | proposedPlans: proposedPlansByThread.get(row.threadId) ?? [], |
apps/server/src/orchestration/Schemas.ts+4| ... | ... | @@ -3,10 +3,12 @@ import { |
| 3 | 3 | ProjectMetaUpdatedPayload as ContractsProjectMetaUpdatedPayloadSchema, |
| 4 | 4 | ProjectDeletedPayload as ContractsProjectDeletedPayloadSchema, |
| 5 | 5 | ThreadCreatedPayload as ContractsThreadCreatedPayloadSchema, |
| 6 | ThreadArchivedPayload as ContractsThreadArchivedPayloadSchema, | |
| 6 | 7 | ThreadMetaUpdatedPayload as ContractsThreadMetaUpdatedPayloadSchema, |
| 7 | 8 | ThreadRuntimeModeSetPayload as ContractsThreadRuntimeModeSetPayloadSchema, |
| 8 | 9 | ThreadInteractionModeSetPayload as ContractsThreadInteractionModeSetPayloadSchema, |
| 9 | 10 | ThreadDeletedPayload as ContractsThreadDeletedPayloadSchema, |
| 11 | ThreadUnarchivedPayload as ContractsThreadUnarchivedPayloadSchema, | |
| 10 | 12 | ThreadMessageSentPayload as ContractsThreadMessageSentPayloadSchema, |
| 11 | 13 | ThreadProposedPlanUpsertedPayload as ContractsThreadProposedPlanUpsertedPayloadSchema, |
| 12 | 14 | ThreadSessionSetPayload as ContractsThreadSessionSetPayloadSchema, |
| ... | ... | @@ -26,10 +28,12 @@ export const ProjectMetaUpdatedPayload = ContractsProjectMetaUpdatedPayloadSchem |
| 26 | 28 | export const ProjectDeletedPayload = ContractsProjectDeletedPayloadSchema; |
| 27 | 29 | |
| 28 | 30 | export const ThreadCreatedPayload = ContractsThreadCreatedPayloadSchema; |
| 31 | export const ThreadArchivedPayload = ContractsThreadArchivedPayloadSchema; | |
| 29 | 32 | export const ThreadMetaUpdatedPayload = ContractsThreadMetaUpdatedPayloadSchema; |
| 30 | 33 | export const ThreadRuntimeModeSetPayload = ContractsThreadRuntimeModeSetPayloadSchema; |
| 31 | 34 | export const ThreadInteractionModeSetPayload = ContractsThreadInteractionModeSetPayloadSchema; |
| 32 | 35 | export const ThreadDeletedPayload = ContractsThreadDeletedPayloadSchema; |
| 36 | export const ThreadUnarchivedPayload = ContractsThreadUnarchivedPayloadSchema; | |
| 33 | 37 | |
| 34 | 38 | export const MessageSentPayloadSchema = ContractsThreadMessageSentPayloadSchema; |
| 35 | 39 | export const ThreadProposedPlanUpsertedPayload = ContractsThreadProposedPlanUpsertedPayloadSchema; |
apps/server/src/orchestration/commandInvariants.test.ts+2| ... | ... | @@ -66,6 +66,7 @@ const readModel: OrchestrationReadModel = { |
| 66 | 66 | worktreePath: null, |
| 67 | 67 | createdAt: now, |
| 68 | 68 | updatedAt: now, |
| 69 | archivedAt: null, | |
| 69 | 70 | latestTurn: null, |
| 70 | 71 | messages: [], |
| 71 | 72 | session: null, |
| ... | ... | @@ -88,6 +89,7 @@ const readModel: OrchestrationReadModel = { |
| 88 | 89 | worktreePath: null, |
| 89 | 90 | createdAt: now, |
| 90 | 91 | updatedAt: now, |
| 92 | archivedAt: null, | |
| 91 | 93 | latestTurn: null, |
| 92 | 94 | messages: [], |
| 93 | 95 | session: null, |
apps/server/src/orchestration/commandInvariants.ts+38| ... | ... | @@ -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 | 129 | export function requireThreadAbsent(input: { |
| 92 | 130 | readonly readModel: OrchestrationReadModel; |
| 93 | 131 | readonly command: OrchestrationCommand; |
apps/server/src/orchestration/decider.ts+47| ... | ... | @@ -10,7 +10,9 @@ import { |
| 10 | 10 | requireProject, |
| 11 | 11 | requireProjectAbsent, |
| 12 | 12 | requireThread, |
| 13 | requireThreadArchived, | |
| 13 | 14 | requireThreadAbsent, |
| 15 | requireThreadNotArchived, | |
| 14 | 16 | } from "./commandInvariants.ts"; |
| 15 | 17 | |
| 16 | 18 | const nowIso = () => new Date().toISOString(); |
| ... | ... | @@ -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 | 240 | case "thread.meta.update": { |
| 194 | 241 | yield* requireThread({ |
| 195 | 242 | readModel, |
apps/server/src/orchestration/projector.test.ts+73| ... | ... | @@ -87,6 +87,7 @@ describe("orchestration projector", () => { |
| 87 | 87 | latestTurn: null, |
| 88 | 88 | createdAt: now, |
| 89 | 89 | updatedAt: now, |
| 90 | archivedAt: null, | |
| 90 | 91 | deletedAt: null, |
| 91 | 92 | messages: [], |
| 92 | 93 | proposedPlans: [], |
| ... | ... | @@ -131,6 +132,78 @@ describe("orchestration projector", () => { |
| 131 | 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 | 207 | it("keeps projector forward-compatible for unhandled event types", async () => { |
| 135 | 208 | const now = new Date().toISOString(); |
| 136 | 209 | const model = createEmptyReadModel(now); |
apps/server/src/orchestration/projector.ts+25| ... | ... | @@ -14,12 +14,14 @@ import { |
| 14 | 14 | ProjectDeletedPayload, |
| 15 | 15 | ProjectMetaUpdatedPayload, |
| 16 | 16 | ThreadActivityAppendedPayload, |
| 17 | ThreadArchivedPayload, | |
| 17 | 18 | ThreadCreatedPayload, |
| 18 | 19 | ThreadDeletedPayload, |
| 19 | 20 | ThreadInteractionModeSetPayload, |
| 20 | 21 | ThreadMetaUpdatedPayload, |
| 21 | 22 | ThreadProposedPlanUpsertedPayload, |
| 22 | 23 | ThreadRuntimeModeSetPayload, |
| 24 | ThreadUnarchivedPayload, | |
| 23 | 25 | ThreadRevertedPayload, |
| 24 | 26 | ThreadSessionSetPayload, |
| 25 | 27 | ThreadTurnDiffCompletedPayload, |
| ... | ... | @@ -260,6 +262,7 @@ export function projectEvent( |
| 260 | 262 | latestTurn: null, |
| 261 | 263 | createdAt: payload.createdAt, |
| 262 | 264 | updatedAt: payload.updatedAt, |
| 265 | archivedAt: null, | |
| 263 | 266 | deletedAt: null, |
| 264 | 267 | messages: [], |
| 265 | 268 | activities: [], |
| ... | ... | @@ -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 | 317 | case "thread.meta-updated": |
| 293 | 318 | return decodeForEvent(ThreadMetaUpdatedPayload, event.payload, event.type, "payload").pipe( |
| 294 | 319 | Effect.map((payload) => ({ |
apps/server/src/persistence/Layers/ProjectionRepositories.test.ts+1| ... | ... | @@ -87,6 +87,7 @@ projectionRepositoriesLayer("Projection repositories", (it) => { |
| 87 | 87 | latestTurnId: null, |
| 88 | 88 | createdAt: "2026-03-24T00:00:00.000Z", |
| 89 | 89 | updatedAt: "2026-03-24T00:00:00.000Z", |
| 90 | archivedAt: null, | |
| 90 | 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 | 39 | latest_turn_id, |
| 40 | 40 | created_at, |
| 41 | 41 | updated_at, |
| 42 | archived_at, | |
| 42 | 43 | deleted_at |
| 43 | 44 | ) |
| 44 | 45 | VALUES ( |
| ... | ... | @@ -53,6 +54,7 @@ const makeProjectionThreadRepository = Effect.gen(function* () { |
| 53 | 54 | ${row.latestTurnId}, |
| 54 | 55 | ${row.createdAt}, |
| 55 | 56 | ${row.updatedAt}, |
| 57 | ${row.archivedAt}, | |
| 56 | 58 | ${row.deletedAt} |
| 57 | 59 | ) |
| 58 | 60 | ON CONFLICT (thread_id) |
| ... | ... | @@ -67,6 +69,7 @@ const makeProjectionThreadRepository = Effect.gen(function* () { |
| 67 | 69 | latest_turn_id = excluded.latest_turn_id, |
| 68 | 70 | created_at = excluded.created_at, |
| 69 | 71 | updated_at = excluded.updated_at, |
| 72 | archived_at = excluded.archived_at, | |
| 70 | 73 | deleted_at = excluded.deleted_at |
| 71 | 74 | `, |
| 72 | 75 | }); |
| ... | ... | @@ -88,6 +91,7 @@ const makeProjectionThreadRepository = Effect.gen(function* () { |
| 88 | 91 | latest_turn_id AS "latestTurnId", |
| 89 | 92 | created_at AS "createdAt", |
| 90 | 93 | updated_at AS "updatedAt", |
| 94 | archived_at AS "archivedAt", | |
| 91 | 95 | deleted_at AS "deletedAt" |
| 92 | 96 | FROM projection_threads |
| 93 | 97 | WHERE thread_id = ${threadId} |
| ... | ... | @@ -111,6 +115,7 @@ const makeProjectionThreadRepository = Effect.gen(function* () { |
| 111 | 115 | latest_turn_id AS "latestTurnId", |
| 112 | 116 | created_at AS "createdAt", |
| 113 | 117 | updated_at AS "updatedAt", |
| 118 | archived_at AS "archivedAt", | |
| 114 | 119 | deleted_at AS "deletedAt" |
| 115 | 120 | FROM projection_threads |
| 116 | 121 | WHERE project_id = ${projectId} |
apps/server/src/persistence/Migrations.ts+4| ... | ... | @@ -29,6 +29,8 @@ import Migration0013 from "./Migrations/013_ProjectionThreadProposedPlans.ts"; |
| 29 | 29 | import Migration0014 from "./Migrations/014_ProjectionThreadProposedPlanImplementation.ts"; |
| 30 | 30 | import Migration0015 from "./Migrations/015_ProjectionTurnsSourceProposedPlan.ts"; |
| 31 | 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 | 36 | * Migration loader with all migrations defined inline. |
| ... | ... | @@ -57,6 +59,8 @@ export const migrationEntries = [ |
| 57 | 59 | [14, "ProjectionThreadProposedPlanImplementation", Migration0014], |
| 58 | 60 | [15, "ProjectionTurnsSourceProposedPlan", Migration0015], |
| 59 | 61 | [16, "CanonicalizeModelSelections", Migration0016], |
| 62 | [17, "ProjectionThreadsArchivedAt", Migration0017], | |
| 63 | [18, "ProjectionThreadsArchivedAtIndex", Migration0018], | |
| 60 | 64 | ] as const; |
| 61 | 65 | |
| 62 | 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 | 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 | 146 | 'event-thread-created', |
| 133 | 147 | 'thread', |
| ... | ... | @@ -284,6 +298,16 @@ layer("016_CanonicalizeModelSelections", (it) => { |
| 284 | 298 | }); |
| 285 | 299 | |
| 286 | 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 | 311 | threadId: "thread-1", |
| 288 | 312 | projectId: "project-1", |
| 289 | 313 | title: "Thread", |
| ... | ... | @@ -303,7 +327,7 @@ layer("016_CanonicalizeModelSelections", (it) => { |
| 303 | 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 | 331 | threadId: "thread-2", |
| 308 | 332 | projectId: "project-1", |
| 309 | 333 | title: "Fallback Thread", |
| ... | ... | @@ -322,7 +346,7 @@ layer("016_CanonicalizeModelSelections", (it) => { |
| 322 | 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 | 350 | threadId: "thread-1", |
| 327 | 351 | turnId: "turn-1", |
| 328 | 352 | input: "hi", |
| ... | ... | @@ -336,7 +360,7 @@ layer("016_CanonicalizeModelSelections", (it) => { |
| 336 | 360 | deliveryMode: "buffered", |
| 337 | 361 | }); |
| 338 | 362 | |
| 339 | assert.deepStrictEqual(JSON.parse(eventRows[5]!.payloadJson), { | |
| 363 | assert.deepStrictEqual(JSON.parse(eventRows[6]!.payloadJson), { | |
| 340 | 364 | threadId: "thread-3", |
| 341 | 365 | projectId: "project-1", |
| 342 | 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 | 32 | latestTurnId: Schema.NullOr(TurnId), |
| 33 | 33 | createdAt: IsoDateTime, |
| 34 | 34 | updatedAt: IsoDateTime, |
| 35 | archivedAt: Schema.NullOr(IsoDateTime), | |
| 35 | 36 | deletedAt: Schema.NullOr(IsoDateTime), |
| 36 | 37 | }); |
| 37 | 38 | export type ProjectionThread = typeof ProjectionThread.Type; |
apps/server/src/terminal/Layers/Manager.test.ts+3-3| ... | ... | @@ -672,9 +672,9 @@ describe("TerminalManager", () => { |
| 672 | 672 | ).toBe(true); |
| 673 | 673 | } else { |
| 674 | 674 | expect( |
| 675 | ptyAdapter.spawnInputs.some((input) => | |
| 676 | ["/bin/zsh", "/bin/bash", "/bin/sh", "zsh", "bash", "sh"].includes(input.shell), | |
| 677 | ), | |
| 675 | ptyAdapter.spawnInputs | |
| 676 | .slice(1) | |
| 677 | .some((input) => input.shell !== "/definitely/missing-shell"), | |
| 678 | 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 | 258 | latestTurn: null, |
| 259 | 259 | createdAt: NOW_ISO, |
| 260 | 260 | updatedAt: NOW_ISO, |
| 261 | archivedAt: null, | |
| 261 | 262 | deletedAt: null, |
| 262 | 263 | messages, |
| 263 | 264 | activities: [], |
| ... | ... | @@ -315,6 +316,7 @@ function addThreadToSnapshot( |
| 315 | 316 | latestTurn: null, |
| 316 | 317 | createdAt: NOW_ISO, |
| 317 | 318 | updatedAt: NOW_ISO, |
| 319 | archivedAt: null, | |
| 318 | 320 | deletedAt: null, |
| 319 | 321 | messages: [], |
| 320 | 322 | activities: [], |
apps/web/src/components/ChatView.logic.ts+1| ... | ... | @@ -32,6 +32,7 @@ export function buildLocalDraftThread( |
| 32 | 32 | messages: [], |
| 33 | 33 | error, |
| 34 | 34 | createdAt: draftThread.createdAt, |
| 35 | archivedAt: null, | |
| 35 | 36 | latestTurn: null, |
| 36 | 37 | lastVisitedAt: draftThread.createdAt, |
| 37 | 38 | branch: draftThread.branch, |
apps/web/src/components/KeybindingsToast.browser.tsx+1| ... | ... | @@ -102,6 +102,7 @@ function createMinimalSnapshot(): OrchestrationReadModel { |
| 102 | 102 | latestTurn: null, |
| 103 | 103 | createdAt: NOW_ISO, |
| 104 | 104 | updatedAt: NOW_ISO, |
| 105 | archivedAt: null, | |
| 105 | 106 | deletedAt: null, |
| 106 | 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 | 380 | proposedPlans: [], |
| 381 | 381 | error: null, |
| 382 | 382 | createdAt: "2026-03-09T10:00:00.000Z", |
| 383 | archivedAt: null, | |
| 383 | 384 | updatedAt: "2026-03-09T10:00:00.000Z", |
| 384 | 385 | latestTurn: null, |
| 385 | 386 | branch: null, |
| ... | ... | @@ -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 | 738 | it("returns the project timestamp when no threads are present", () => { |
| 701 | 739 | const timestamp = getProjectSortTimestamp( |
| 702 | 740 | makeProject({ updatedAt: "2026-03-09T10:10:00.000Z" }), |
apps/web/src/components/Sidebar.tsx+336-408| ... | ... | @@ -1,5 +1,5 @@ |
| 1 | 1 | import { |
| 2 | ArrowLeftIcon, | |
| 2 | ArchiveIcon, | |
| 3 | 3 | ArrowUpDownIcon, |
| 4 | 4 | ChevronRightIcon, |
| 5 | 5 | FolderIcon, |
| ... | ... | @@ -11,6 +11,7 @@ import { |
| 11 | 11 | TerminalIcon, |
| 12 | 12 | TriangleAlertIcon, |
| 13 | 13 | } from "lucide-react"; |
| 14 | import { ProjectFavicon } from "./ProjectFavicon"; | |
| 14 | 15 | import { autoAnimate } from "@formkit/auto-animate"; |
| 15 | 16 | import { useCallback, useEffect, useMemo, useRef, useState, type MouseEvent } from "react"; |
| 16 | 17 | import { |
| ... | ... | @@ -36,7 +37,7 @@ import { |
| 36 | 37 | type GitStatusResult, |
| 37 | 38 | type ResolvedKeybindingsConfig, |
| 38 | 39 | } from "@t3tools/contracts"; |
| 39 | import { useMutation, useQueries, useQuery, useQueryClient } from "@tanstack/react-query"; | |
| 40 | import { useQueries, useQuery } from "@tanstack/react-query"; | |
| 40 | 41 | import { useLocation, useNavigate, useParams } from "@tanstack/react-router"; |
| 41 | 42 | import { |
| 42 | 43 | type SidebarProjectSortOrder, |
| ... | ... | @@ -48,13 +49,16 @@ import { isLinuxPlatform, isMacPlatform, newCommandId, newProjectId } from "../l |
| 48 | 49 | import { useStore } from "../store"; |
| 49 | 50 | import { shortcutLabelForCommand } from "../keybindings"; |
| 50 | 51 | import { derivePendingApprovals, derivePendingUserInputs } from "../session-logic"; |
| 51 | import { gitRemoveWorktreeMutationOptions, gitStatusQueryOptions } from "../lib/gitReactQuery"; | |
| 52 | import { gitStatusQueryOptions } from "../lib/gitReactQuery"; | |
| 52 | 53 | import { serverConfigQueryOptions } from "../lib/serverReactQuery"; |
| 53 | 54 | import { readNativeApi } from "../nativeApi"; |
| 54 | 55 | import { useComposerDraftStore } from "../composerDraftStore"; |
| 55 | 56 | import { useHandleNewThread } from "../hooks/useHandleNewThread"; |
| 57 | import { useThreadActions } from "../hooks/useThreadActions"; | |
| 56 | 58 | import { selectThreadTerminalState, useTerminalStateStore } from "../terminalStateStore"; |
| 57 | 59 | import { toastManager } from "./ui/toast"; |
| 60 | import { formatRelativeTimeLabel } from "../timestampFormat"; | |
| 61 | import { SettingsSidebarNav } from "./settings/SettingsSidebarNav"; | |
| 58 | 62 | import { |
| 59 | 63 | getArm64IntelBuildWarningDescription, |
| 60 | 64 | getDesktopUpdateActionError, |
| ... | ... | @@ -87,10 +91,8 @@ import { |
| 87 | 91 | SidebarTrigger, |
| 88 | 92 | } from "./ui/sidebar"; |
| 89 | 93 | import { useThreadSelectionStore } from "../threadSelectionStore"; |
| 90 | import { formatWorktreePathForDisplay, getOrphanedWorktreePathForThread } from "../worktreeCleanup"; | |
| 91 | 94 | import { isNonEmpty as isNonEmptyString } from "effect/String"; |
| 92 | 95 | import { |
| 93 | getFallbackThreadIdAfterDelete, | |
| 94 | 96 | getVisibleThreadsForProject, |
| 95 | 97 | resolveProjectStatusIndicator, |
| 96 | 98 | resolveSidebarNewThreadEnvMode, |
| ... | ... | @@ -118,18 +120,6 @@ const SIDEBAR_LIST_ANIMATION_OPTIONS = { |
| 118 | 120 | duration: 180, |
| 119 | 121 | easing: "ease-out", |
| 120 | 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 | 123 | interface TerminalStatusIndicator { |
| 134 | 124 | label: "Terminal process running"; |
| 135 | 125 | colorClass: string; |
| ... | ... | @@ -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 | 197 | type SortableProjectHandleProps = Pick< |
| 256 | 198 | ReturnType<typeof useSortable>, |
| 257 | 199 | "attributes" | "listeners" | "setActivatorNodeRef" |
| ... | ... | @@ -372,18 +314,16 @@ export default function Sidebar() { |
| 372 | 314 | (store) => store.getDraftThreadByProjectId, |
| 373 | 315 | ); |
| 374 | 316 | const terminalStateByThreadId = useTerminalStateStore((state) => state.terminalStateByThreadId); |
| 375 | const clearTerminalState = useTerminalStateStore((state) => state.clearTerminalState); | |
| 376 | 317 | const clearProjectDraftThreadId = useComposerDraftStore( |
| 377 | 318 | (store) => store.clearProjectDraftThreadId, |
| 378 | 319 | ); |
| 379 | const clearProjectDraftThreadById = useComposerDraftStore( | |
| 380 | (store) => store.clearProjectDraftThreadById, | |
| 381 | ); | |
| 382 | 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 | 323 | const appSettings = useSettings(); |
| 385 | 324 | const { updateSettings } = useUpdateSettings(); |
| 386 | 325 | const { handleNewThread } = useHandleNewThread(); |
| 326 | const { archiveThread, deleteThread } = useThreadActions(); | |
| 387 | 327 | const routeThreadId = useParams({ |
| 388 | 328 | strict: false, |
| 389 | 329 | select: (params) => (params.threadId ? ThreadId.makeUnsafe(params.threadId) : null), |
| ... | ... | @@ -392,8 +332,6 @@ export default function Sidebar() { |
| 392 | 332 | ...serverConfigQueryOptions(), |
| 393 | 333 | select: (config) => config.keybindings, |
| 394 | 334 | }); |
| 395 | const queryClient = useQueryClient(); | |
| 396 | const removeWorktreeMutation = useMutation(gitRemoveWorktreeMutationOptions({ queryClient })); | |
| 397 | 335 | const [addingProject, setAddingProject] = useState(false); |
| 398 | 336 | const [newCwd, setNewCwd] = useState(""); |
| 399 | 337 | const [isPickingFolder, setIsPickingFolder] = useState(false); |
| ... | ... | @@ -402,6 +340,8 @@ export default function Sidebar() { |
| 402 | 340 | const addProjectInputRef = useRef<HTMLInputElement | null>(null); |
| 403 | 341 | const [renamingThreadId, setRenamingThreadId] = useState<ThreadId | null>(null); |
| 404 | 342 | const [renamingTitle, setRenamingTitle] = useState(""); |
| 343 | const [hoveredThreadId, setHoveredThreadId] = useState<ThreadId | null>(null); | |
| 344 | const [confirmingArchiveThreadId, setConfirmingArchiveThreadId] = useState<ThreadId | null>(null); | |
| 405 | 345 | const [expandedThreadListsByProject, setExpandedThreadListsByProject] = useState< |
| 406 | 346 | ReadonlySet<ProjectId> |
| 407 | 347 | >(() => new Set()); |
| ... | ... | @@ -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 | 451 | const focusMostRecentThreadForProject = useCallback( |
| 497 | 452 | (projectId: ProjectId) => { |
| 498 | 453 | const latestThread = sortThreadsForSidebar( |
| 499 | threads.filter((thread) => thread.projectId === projectId), | |
| 454 | threads.filter((thread) => thread.projectId === projectId && thread.archivedAt === null), | |
| 500 | 455 | appSettings.sidebarThreadSortOrder, |
| 501 | 456 | )[0]; |
| 502 | 457 | if (!latestThread) return; |
| ... | ... | @@ -627,7 +582,10 @@ export default function Sidebar() { |
| 627 | 582 | |
| 628 | 583 | const trimmed = newTitle.trim(); |
| 629 | 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 | 589 | finishRename(); |
| 632 | 590 | return; |
| 633 | 591 | } |
| ... | ... | @@ -659,129 +617,9 @@ export default function Sidebar() { |
| 659 | 617 | [], |
| 660 | 618 | ); |
| 661 | 619 | |
| 662 | /** | |
| 663 | * Delete a single thread: stop session, close terminal, dispatch delete, | |
| 664 | * clean up drafts/state, and optionally remove orphaned worktree. | |
| 665 | * Callers handle thread-level confirmation; this still prompts for worktree removal. | |
| 666 | */ | |
| 667 | const deleteThread = useCallback( | |
| 668 | async ( | |
| 669 | threadId: ThreadId, | |
| 670 | opts: { deletedThreadIds?: ReadonlySet<ThreadId> } = {}, | |
| 671 | ): Promise<void> => { | |
| 672 | const api = readNativeApi(); | |
| 673 | if (!api) return; | |
| 674 | const thread = threads.find((t) => t.id === threadId); | |
| 675 | if (!thread) return; | |
| 676 | const threadProject = projects.find((project) => project.id === thread.projectId); | |
| 677 | // When bulk-deleting, exclude the other threads being deleted so | |
| 678 | // getOrphanedWorktreePathForThread correctly detects that no surviving | |
| 679 | // threads will reference this worktree. | |
| 680 | const deletedIds = opts.deletedThreadIds; | |
| 681 | const survivingThreads = | |
| 682 | deletedIds && deletedIds.size > 0 | |
| 683 | ? threads.filter((t) => t.id === threadId || !deletedIds.has(t.id)) | |
| 684 | : threads; | |
| 685 | const orphanedWorktreePath = getOrphanedWorktreePathForThread(survivingThreads, threadId); | |
| 686 | const displayWorktreePath = orphanedWorktreePath | |
| 687 | ? formatWorktreePathForDisplay(orphanedWorktreePath) | |
| 688 | : null; | |
| 689 | const canDeleteWorktree = orphanedWorktreePath !== null && threadProject !== undefined; | |
| 690 | const shouldDeleteWorktree = | |
| 691 | canDeleteWorktree && | |
| 692 | (await api.dialogs.confirm( | |
| 693 | [ | |
| 694 | "This thread is the only one linked to this worktree:", | |
| 695 | displayWorktreePath ?? orphanedWorktreePath, | |
| 696 | "", | |
| 697 | "Delete the worktree too?", | |
| 698 | ].join("\n"), | |
| 699 | )); | |
| 700 | ||
| 701 | if (thread.session && thread.session.status !== "closed") { | |
| 702 | await api.orchestration | |
| 703 | .dispatchCommand({ | |
| 704 | type: "thread.session.stop", | |
| 705 | commandId: newCommandId(), | |
| 706 | threadId, | |
| 707 | createdAt: new Date().toISOString(), | |
| 708 | }) | |
| 709 | .catch(() => undefined); | |
| 710 | } | |
| 711 | ||
| 712 | try { | |
| 713 | await api.terminal.close({ threadId, deleteHistory: true }); | |
| 714 | } catch { | |
| 715 | // Terminal may already be closed | |
| 716 | } | |
| 717 | ||
| 718 | const allDeletedIds = deletedIds ?? new Set<ThreadId>(); | |
| 719 | const shouldNavigateToFallback = routeThreadId === threadId; | |
| 720 | const fallbackThreadId = getFallbackThreadIdAfterDelete({ | |
| 721 | threads, | |
| 722 | deletedThreadId: threadId, | |
| 723 | deletedThreadIds: allDeletedIds, | |
| 724 | sortOrder: appSettings.sidebarThreadSortOrder, | |
| 725 | }); | |
| 726 | await api.orchestration.dispatchCommand({ | |
| 727 | type: "thread.delete", | |
| 728 | commandId: newCommandId(), | |
| 729 | threadId, | |
| 730 | }); | |
| 731 | clearComposerDraftForThread(threadId); | |
| 732 | clearProjectDraftThreadById(thread.projectId, thread.id); | |
| 733 | clearTerminalState(threadId); | |
| 734 | if (shouldNavigateToFallback) { | |
| 735 | if (fallbackThreadId) { | |
| 736 | void navigate({ | |
| 737 | to: "/$threadId", | |
| 738 | params: { threadId: fallbackThreadId }, | |
| 739 | replace: true, | |
| 740 | }); | |
| 741 | } else { | |
| 742 | void navigate({ to: "/", replace: true }); | |
| 743 | } | |
| 744 | } | |
| 745 | ||
| 746 | if (!shouldDeleteWorktree || !orphanedWorktreePath || !threadProject) { | |
| 747 | return; | |
| 748 | } | |
| 749 | ||
| 750 | try { | |
| 751 | await removeWorktreeMutation.mutateAsync({ | |
| 752 | cwd: threadProject.cwd, | |
| 753 | path: orphanedWorktreePath, | |
| 754 | force: true, | |
| 755 | }); | |
| 756 | } catch (error) { | |
| 757 | const message = error instanceof Error ? error.message : "Unknown error removing worktree."; | |
| 758 | console.error("Failed to remove orphaned worktree after thread deletion", { | |
| 759 | threadId, | |
| 760 | projectCwd: threadProject.cwd, | |
| 761 | worktreePath: orphanedWorktreePath, | |
| 762 | error, | |
| 763 | }); | |
| 764 | toastManager.add({ | |
| 765 | type: "error", | |
| 766 | title: "Thread deleted, but worktree removal failed", | |
| 767 | description: `Could not remove ${displayWorktreePath ?? orphanedWorktreePath}. ${message}`, | |
| 768 | }); | |
| 769 | } | |
| 770 | }, | |
| 771 | [ | |
| 772 | appSettings.sidebarThreadSortOrder, | |
| 773 | clearComposerDraftForThread, | |
| 774 | clearProjectDraftThreadById, | |
| 775 | clearTerminalState, | |
| 776 | navigate, | |
| 777 | projects, | |
| 778 | removeWorktreeMutation, | |
| 779 | routeThreadId, | |
| 780 | threads, | |
| 781 | ], | |
| 782 | ); | |
| 783 | ||
| 784 | const { copyToClipboard: copyThreadIdToClipboard } = useCopyToClipboard<{ threadId: ThreadId }>({ | |
| 620 | const { copyToClipboard: copyThreadIdToClipboard } = useCopyToClipboard<{ | |
| 621 | threadId: ThreadId; | |
| 622 | }>({ | |
| 785 | 623 | onCopy: (ctx) => { |
| 786 | 624 | toastManager.add({ |
| 787 | 625 | type: "success", |
| ... | ... | @@ -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 | 641 | onCopy: (ctx) => { |
| 802 | 642 | toastManager.add({ |
| 803 | 643 | type: "success", |
| ... | ... | @@ -1098,9 +938,13 @@ export default function Sidebar() { |
| 1098 | 938 | suppressProjectClickAfterDragRef.current = false; |
| 1099 | 939 | }, []); |
| 1100 | 940 | |
| 941 | const visibleThreads = useMemo( | |
| 942 | () => threads.filter((thread) => thread.archivedAt === null), | |
| 943 | [threads], | |
| 944 | ); | |
| 1101 | 945 | const sortedProjects = useMemo( |
| 1102 | () => sortProjectsForSidebar(projects, threads, appSettings.sidebarProjectSortOrder), | |
| 1103 | [appSettings.sidebarProjectSortOrder, projects, threads], | |
| 946 | () => sortProjectsForSidebar(projects, visibleThreads, appSettings.sidebarProjectSortOrder), | |
| 947 | [appSettings.sidebarProjectSortOrder, projects, visibleThreads], | |
| 1104 | 948 | ); |
| 1105 | 949 | const isManualProjectSorting = appSettings.sidebarProjectSortOrder === "manual"; |
| 1106 | 950 | |
| ... | ... | @@ -1109,7 +953,7 @@ export default function Sidebar() { |
| 1109 | 953 | dragHandleProps: SortableProjectHandleProps | null, |
| 1110 | 954 | ) { |
| 1111 | 955 | const projectThreads = sortThreadsForSidebar( |
| 1112 | threads.filter((thread) => thread.projectId === project.id), | |
| 956 | visibleThreads.filter((thread) => thread.projectId === project.id), | |
| 1113 | 957 | appSettings.sidebarThreadSortOrder, |
| 1114 | 958 | ); |
| 1115 | 959 | const projectStatus = resolveProjectStatusIndicator( |
| ... | ... | @@ -1128,18 +972,22 @@ export default function Sidebar() { |
| 1128 | 972 | ? (projectThreads.find((thread) => thread.id === activeThreadId) ?? null) |
| 1129 | 973 | : null; |
| 1130 | 974 | const shouldShowThreadPanel = project.expanded || pinnedCollapsedThread !== null; |
| 1131 | const { hasHiddenThreads, visibleThreads } = getVisibleThreadsForProject({ | |
| 1132 | threads: projectThreads, | |
| 1133 | activeThreadId, | |
| 1134 | isThreadListExpanded, | |
| 1135 | previewLimit: THREAD_PREVIEW_LIMIT, | |
| 1136 | }); | |
| 975 | const { hasHiddenThreads, visibleThreads: visibleProjectThreads } = getVisibleThreadsForProject( | |
| 976 | { | |
| 977 | threads: projectThreads, | |
| 978 | activeThreadId, | |
| 979 | isThreadListExpanded, | |
| 980 | previewLimit: THREAD_PREVIEW_LIMIT, | |
| 981 | }, | |
| 982 | ); | |
| 1137 | 983 | const orderedProjectThreadIds = projectThreads.map((thread) => thread.id); |
| 1138 | const renderedThreads = pinnedCollapsedThread ? [pinnedCollapsedThread] : visibleThreads; | |
| 984 | const renderedThreads = pinnedCollapsedThread ? [pinnedCollapsedThread] : visibleProjectThreads; | |
| 1139 | 985 | const renderThreadRow = (thread: (typeof projectThreads)[number]) => { |
| 1140 | 986 | const isActive = routeThreadId === thread.id; |
| 1141 | 987 | const isSelected = selectedThreadIds.has(thread.id); |
| 1142 | 988 | const isHighlighted = isActive || isSelected; |
| 989 | const isThreadRunning = | |
| 990 | thread.session?.status === "running" && thread.session.activeTurnId != null; | |
| 1143 | 991 | const threadStatus = resolveThreadStatusPill({ |
| 1144 | 992 | thread, |
| 1145 | 993 | hasPendingApprovals: derivePendingApprovals(thread.activities).length > 0, |
| ... | ... | @@ -1151,15 +999,26 @@ export default function Sidebar() { |
| 1151 | 999 | ); |
| 1152 | 1000 | |
| 1153 | 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 | 1014 | <SidebarMenuSubButton |
| 1156 | 1015 | render={<div role="button" tabIndex={0} />} |
| 1157 | 1016 | size="sm" |
| 1158 | 1017 | isActive={isActive} |
| 1159 | className={resolveThreadRowClassName({ | |
| 1018 | className={`${resolveThreadRowClassName({ | |
| 1160 | 1019 | isActive, |
| 1161 | 1020 | isSelected, |
| 1162 | })} | |
| 1021 | })} relative`} | |
| 1163 | 1022 | onClick={(event) => { |
| 1164 | 1023 | handleThreadClick(event, thread.id, orderedProjectThreadIds); |
| 1165 | 1024 | }} |
| ... | ... | @@ -1177,6 +1036,7 @@ export default function Sidebar() { |
| 1177 | 1036 | }} |
| 1178 | 1037 | onContextMenu={(event) => { |
| 1179 | 1038 | event.preventDefault(); |
| 1039 | setHoveredThreadId((current) => (current === thread.id ? null : current)); | |
| 1180 | 1040 | if (selectedThreadIds.size > 0 && selectedThreadIds.has(thread.id)) { |
| 1181 | 1041 | void handleMultiSelectContextMenu({ |
| 1182 | 1042 | x: event.clientX, |
| ... | ... | @@ -1273,15 +1133,86 @@ export default function Sidebar() { |
| 1273 | 1133 | /> |
| 1274 | 1134 | </span> |
| 1275 | 1135 | )} |
| 1276 | <span | |
| 1277 | className={`text-[10px] ${ | |
| 1278 | isHighlighted | |
| 1279 | ? "text-foreground/72 dark:text-foreground/82" | |
| 1280 | : "text-muted-foreground/40" | |
| 1281 | }`} | |
| 1282 | > | |
| 1283 | {formatRelativeTime(thread.updatedAt ?? thread.createdAt)} | |
| 1284 | </span> | |
| 1136 | <div className="flex min-w-12 justify-end"> | |
| 1137 | {confirmingArchiveThreadId === thread.id && !isThreadRunning ? ( | |
| 1138 | <button | |
| 1139 | type="button" | |
| 1140 | data-thread-selection-safe | |
| 1141 | aria-label={`Confirm archive ${thread.title}`} | |
| 1142 | className="absolute top-1/2 right-1 inline-flex h-5 -translate-y-1/2 cursor-pointer items-center rounded-full bg-destructive/12 px-2 text-[10px] font-medium text-destructive transition-colors hover:bg-destructive/18 focus-visible:outline-hidden focus-visible:ring-1 focus-visible:ring-destructive/40" | |
| 1143 | onPointerDown={(event) => { | |
| 1144 | event.stopPropagation(); | |
| 1145 | }} | |
| 1146 | onClick={(event) => { | |
| 1147 | event.preventDefault(); | |
| 1148 | event.stopPropagation(); | |
| 1149 | setConfirmingArchiveThreadId((current) => | |
| 1150 | current === thread.id ? null : current, | |
| 1151 | ); | |
| 1152 | setHoveredThreadId((current) => (current === thread.id ? null : current)); | |
| 1153 | void attemptArchiveThread(thread.id); | |
| 1154 | }} | |
| 1155 | > | |
| 1156 | Confirm | |
| 1157 | </button> | |
| 1158 | ) : hoveredThreadId === thread.id && !isThreadRunning ? ( | |
| 1159 | appSettings.confirmThreadArchive ? ( | |
| 1160 | <button | |
| 1161 | type="button" | |
| 1162 | data-thread-selection-safe | |
| 1163 | aria-label={`Archive ${thread.title}`} | |
| 1164 | className="absolute top-1/2 right-1 inline-flex size-5 -translate-y-1/2 cursor-pointer items-center justify-center text-muted-foreground transition-colors hover:text-foreground focus-visible:outline-hidden focus-visible:ring-1 focus-visible:ring-ring" | |
| 1165 | onPointerDown={(event) => { | |
| 1166 | event.stopPropagation(); | |
| 1167 | }} | |
| 1168 | onClick={(event) => { | |
| 1169 | event.preventDefault(); | |
| 1170 | event.stopPropagation(); | |
| 1171 | setConfirmingArchiveThreadId(thread.id); | |
| 1172 | }} | |
| 1173 | > | |
| 1174 | <ArchiveIcon className="size-3.5" /> | |
| 1175 | </button> | |
| 1176 | ) : ( | |
| 1177 | <Tooltip> | |
| 1178 | <TooltipTrigger | |
| 1179 | render={ | |
| 1180 | <button | |
| 1181 | type="button" | |
| 1182 | data-thread-selection-safe | |
| 1183 | aria-label={`Archive ${thread.title}`} | |
| 1184 | className="absolute top-1/2 right-1 inline-flex size-5 -translate-y-1/2 cursor-pointer items-center justify-center text-muted-foreground transition-colors hover:text-foreground focus-visible:outline-hidden focus-visible:ring-1 focus-visible:ring-ring" | |
| 1185 | onPointerDown={(event) => { | |
| 1186 | event.stopPropagation(); | |
| 1187 | }} | |
| 1188 | onClick={(event) => { | |
| 1189 | event.preventDefault(); | |
| 1190 | event.stopPropagation(); | |
| 1191 | setHoveredThreadId((current) => | |
| 1192 | current === thread.id ? null : current, | |
| 1193 | ); | |
| 1194 | void attemptArchiveThread(thread.id); | |
| 1195 | }} | |
| 1196 | > | |
| 1197 | <ArchiveIcon className="size-3.5" /> | |
| 1198 | </button> | |
| 1199 | } | |
| 1200 | /> | |
| 1201 | <TooltipPopup side="top">Archive</TooltipPopup> | |
| 1202 | </Tooltip> | |
| 1203 | ) | |
| 1204 | ) : ( | |
| 1205 | <span | |
| 1206 | className={`text-[10px] ${ | |
| 1207 | isHighlighted | |
| 1208 | ? "text-foreground/72 dark:text-foreground/82" | |
| 1209 | : "text-muted-foreground/40" | |
| 1210 | }`} | |
| 1211 | > | |
| 1212 | {formatRelativeTimeLabel(thread.updatedAt ?? thread.createdAt)} | |
| 1213 | </span> | |
| 1214 | )} | |
| 1215 | </div> | |
| 1285 | 1216 | </div> |
| 1286 | 1217 | </SidebarMenuSubButton> |
| 1287 | 1218 | </SidebarMenuSubItem> |
| ... | ... | @@ -1349,7 +1280,7 @@ export default function Sidebar() { |
| 1349 | 1280 | /> |
| 1350 | 1281 | } |
| 1351 | 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 | 1284 | onClick={(event) => { |
| 1354 | 1285 | event.preventDefault(); |
| 1355 | 1286 | event.stopPropagation(); |
| ... | ... | @@ -1658,202 +1589,199 @@ export default function Sidebar() { |
| 1658 | 1589 | </SidebarHeader> |
| 1659 | 1590 | )} |
| 1660 | 1591 | |
| 1661 | <SidebarContent className="gap-0"> | |
| 1662 | {showArm64IntelBuildWarning && arm64IntelBuildWarningDescription ? ( | |
| 1663 | <SidebarGroup className="px-2 pt-2 pb-0"> | |
| 1664 | <Alert variant="warning" className="rounded-2xl border-warning/40 bg-warning/8"> | |
| 1665 | <TriangleAlertIcon /> | |
| 1666 | <AlertTitle>Intel build on Apple Silicon</AlertTitle> | |
| 1667 | <AlertDescription>{arm64IntelBuildWarningDescription}</AlertDescription> | |
| 1668 | {desktopUpdateButtonAction !== "none" ? ( | |
| 1669 | <AlertAction> | |
| 1670 | <Button | |
| 1671 | size="xs" | |
| 1672 | variant="outline" | |
| 1673 | disabled={desktopUpdateButtonDisabled} | |
| 1674 | onClick={handleDesktopUpdateButtonClick} | |
| 1675 | > | |
| 1676 | {desktopUpdateButtonAction === "download" | |
| 1677 | ? "Download ARM build" | |
| 1678 | : "Install ARM build"} | |
| 1679 | </Button> | |
| 1680 | </AlertAction> | |
| 1681 | ) : null} | |
| 1682 | </Alert> | |
| 1683 | </SidebarGroup> | |
| 1684 | ) : null} | |
| 1685 | <SidebarGroup className="px-2 py-2"> | |
| 1686 | <div className="mb-1 flex items-center justify-between px-2"> | |
| 1687 | <span className="text-[10px] font-medium uppercase tracking-wider text-muted-foreground/60"> | |
| 1688 | Projects | |
| 1689 | </span> | |
| 1690 | <div className="flex items-center gap-1"> | |
| 1691 | <ProjectSortMenu | |
| 1692 | projectSortOrder={appSettings.sidebarProjectSortOrder} | |
| 1693 | threadSortOrder={appSettings.sidebarThreadSortOrder} | |
| 1694 | onProjectSortOrderChange={(sortOrder) => { | |
| 1695 | updateSettings({ sidebarProjectSortOrder: sortOrder }); | |
| 1696 | }} | |
| 1697 | onThreadSortOrderChange={(sortOrder) => { | |
| 1698 | updateSettings({ sidebarThreadSortOrder: sortOrder }); | |
| 1699 | }} | |
| 1700 | /> | |
| 1701 | <Tooltip> | |
| 1702 | <TooltipTrigger | |
| 1703 | render={ | |
| 1592 | {isOnSettings ? ( | |
| 1593 | <SettingsSidebarNav pathname={pathname} /> | |
| 1594 | ) : ( | |
| 1595 | <> | |
| 1596 | <SidebarContent className="gap-0"> | |
| 1597 | {showArm64IntelBuildWarning && arm64IntelBuildWarningDescription ? ( | |
| 1598 | <SidebarGroup className="px-2 pt-2 pb-0"> | |
| 1599 | <Alert variant="warning" className="rounded-2xl border-warning/40 bg-warning/8"> | |
| 1600 | <TriangleAlertIcon /> | |
| 1601 | <AlertTitle>Intel build on Apple Silicon</AlertTitle> | |
| 1602 | <AlertDescription>{arm64IntelBuildWarningDescription}</AlertDescription> | |
| 1603 | {desktopUpdateButtonAction !== "none" ? ( | |
| 1604 | <AlertAction> | |
| 1605 | <Button | |
| 1606 | size="xs" | |
| 1607 | variant="outline" | |
| 1608 | disabled={desktopUpdateButtonDisabled} | |
| 1609 | onClick={handleDesktopUpdateButtonClick} | |
| 1610 | > | |
| 1611 | {desktopUpdateButtonAction === "download" | |
| 1612 | ? "Download ARM build" | |
| 1613 | : "Install ARM build"} | |
| 1614 | </Button> | |
| 1615 | </AlertAction> | |
| 1616 | ) : null} | |
| 1617 | </Alert> | |
| 1618 | </SidebarGroup> | |
| 1619 | ) : null} | |
| 1620 | <SidebarGroup className="px-2 py-2"> | |
| 1621 | <div className="mb-1 flex items-center justify-between pl-2 pr-1.5"> | |
| 1622 | <span className="text-[10px] font-medium uppercase tracking-wider text-muted-foreground/60"> | |
| 1623 | Projects | |
| 1624 | </span> | |
| 1625 | <div className="flex items-center gap-1"> | |
| 1626 | <ProjectSortMenu | |
| 1627 | projectSortOrder={appSettings.sidebarProjectSortOrder} | |
| 1628 | threadSortOrder={appSettings.sidebarThreadSortOrder} | |
| 1629 | onProjectSortOrderChange={(sortOrder) => { | |
| 1630 | updateSettings({ sidebarProjectSortOrder: sortOrder }); | |
| 1631 | }} | |
| 1632 | onThreadSortOrderChange={(sortOrder) => { | |
| 1633 | updateSettings({ sidebarThreadSortOrder: sortOrder }); | |
| 1634 | }} | |
| 1635 | /> | |
| 1636 | <Tooltip> | |
| 1637 | <TooltipTrigger | |
| 1638 | render={ | |
| 1639 | <button | |
| 1640 | type="button" | |
| 1641 | aria-label={ | |
| 1642 | shouldShowProjectPathEntry ? "Cancel add project" : "Add project" | |
| 1643 | } | |
| 1644 | aria-pressed={shouldShowProjectPathEntry} | |
| 1645 | className="inline-flex size-5 cursor-pointer items-center justify-center rounded-md text-muted-foreground/60 transition-colors hover:bg-accent hover:text-foreground" | |
| 1646 | onClick={handleStartAddProject} | |
| 1647 | /> | |
| 1648 | } | |
| 1649 | > | |
| 1650 | <PlusIcon | |
| 1651 | className={`size-3.5 transition-transform duration-150 ${ | |
| 1652 | shouldShowProjectPathEntry ? "rotate-45" : "rotate-0" | |
| 1653 | }`} | |
| 1654 | /> | |
| 1655 | </TooltipTrigger> | |
| 1656 | <TooltipPopup side="right"> | |
| 1657 | {shouldShowProjectPathEntry ? "Cancel add project" : "Add project"} | |
| 1658 | </TooltipPopup> | |
| 1659 | </Tooltip> | |
| 1660 | </div> | |
| 1661 | </div> | |
| 1662 | ||
| 1663 | {shouldShowProjectPathEntry && ( | |
| 1664 | <div className="mb-2 px-1"> | |
| 1665 | {isElectron && ( | |
| 1704 | 1666 | <button |
| 1705 | 1667 | type="button" |
| 1706 | aria-label={shouldShowProjectPathEntry ? "Cancel add project" : "Add project"} | |
| 1707 | aria-pressed={shouldShowProjectPathEntry} | |
| 1708 | className="inline-flex size-5 cursor-pointer items-center justify-center rounded-md text-muted-foreground/60 transition-colors hover:bg-accent hover:text-foreground" | |
| 1709 | onClick={handleStartAddProject} | |
| 1668 | className="mb-1.5 flex w-full items-center justify-center gap-2 rounded-md border border-border bg-secondary py-1.5 text-xs text-foreground/80 transition-colors duration-150 hover:bg-accent hover:text-foreground disabled:cursor-not-allowed disabled:opacity-60" | |
| 1669 | onClick={() => void handlePickFolder()} | |
| 1670 | disabled={isPickingFolder || isAddingProject} | |
| 1671 | > | |
| 1672 | <FolderIcon className="size-3.5" /> | |
| 1673 | {isPickingFolder ? "Picking folder..." : "Browse for folder"} | |
| 1674 | </button> | |
| 1675 | )} | |
| 1676 | <div className="flex gap-1.5"> | |
| 1677 | <input | |
| 1678 | ref={addProjectInputRef} | |
| 1679 | className={`min-w-0 flex-1 rounded-md border bg-secondary px-2 py-1 font-mono text-xs text-foreground placeholder:text-muted-foreground/40 focus:outline-none ${ | |
| 1680 | addProjectError | |
| 1681 | ? "border-red-500/70 focus:border-red-500" | |
| 1682 | : "border-border focus:border-ring" | |
| 1683 | }`} | |
| 1684 | placeholder="/path/to/project" | |
| 1685 | value={newCwd} | |
| 1686 | onChange={(event) => { | |
| 1687 | setNewCwd(event.target.value); | |
| 1688 | setAddProjectError(null); | |
| 1689 | }} | |
| 1690 | onKeyDown={(event) => { | |
| 1691 | if (event.key === "Enter") handleAddProject(); | |
| 1692 | if (event.key === "Escape") { | |
| 1693 | setAddingProject(false); | |
| 1694 | setAddProjectError(null); | |
| 1695 | } | |
| 1696 | }} | |
| 1697 | autoFocus | |
| 1710 | 1698 | /> |
| 1711 | } | |
| 1712 | > | |
| 1713 | <PlusIcon | |
| 1714 | className={`size-3.5 transition-transform duration-150 ${ | |
| 1715 | shouldShowProjectPathEntry ? "rotate-45" : "rotate-0" | |
| 1716 | }`} | |
| 1717 | /> | |
| 1718 | </TooltipTrigger> | |
| 1719 | <TooltipPopup side="right"> | |
| 1720 | {shouldShowProjectPathEntry ? "Cancel add project" : "Add project"} | |
| 1721 | </TooltipPopup> | |
| 1722 | </Tooltip> | |
| 1723 | </div> | |
| 1724 | </div> | |
| 1699 | <button | |
| 1700 | type="button" | |
| 1701 | className="shrink-0 rounded-md bg-primary px-2.5 py-1 text-xs font-medium text-primary-foreground transition-colors duration-150 hover:bg-primary/90 disabled:opacity-60" | |
| 1702 | onClick={handleAddProject} | |
| 1703 | disabled={!canAddProject} | |
| 1704 | > | |
| 1705 | {isAddingProject ? "Adding..." : "Add"} | |
| 1706 | </button> | |
| 1707 | </div> | |
| 1708 | {addProjectError && ( | |
| 1709 | <p className="mt-1 px-0.5 text-[11px] leading-tight text-red-400"> | |
| 1710 | {addProjectError} | |
| 1711 | </p> | |
| 1712 | )} | |
| 1713 | <div className="mt-1.5 px-0.5"> | |
| 1714 | <button | |
| 1715 | type="button" | |
| 1716 | className="text-[11px] text-muted-foreground/50 transition-colors hover:text-muted-foreground" | |
| 1717 | onClick={() => { | |
| 1718 | setAddingProject(false); | |
| 1719 | setAddProjectError(null); | |
| 1720 | }} | |
| 1721 | > | |
| 1722 | Cancel | |
| 1723 | </button> | |
| 1724 | </div> | |
| 1725 | </div> | |
| 1726 | )} | |
| 1725 | 1727 | |
| 1726 | {shouldShowProjectPathEntry && ( | |
| 1727 | <div className="mb-2 px-1"> | |
| 1728 | {isElectron && ( | |
| 1729 | <button | |
| 1730 | type="button" | |
| 1731 | className="mb-1.5 flex w-full items-center justify-center gap-2 rounded-md border border-border bg-secondary py-1.5 text-xs text-foreground/80 transition-colors duration-150 hover:bg-accent hover:text-foreground disabled:cursor-not-allowed disabled:opacity-60" | |
| 1732 | onClick={() => void handlePickFolder()} | |
| 1733 | disabled={isPickingFolder || isAddingProject} | |
| 1728 | {isManualProjectSorting ? ( | |
| 1729 | <DndContext | |
| 1730 | sensors={projectDnDSensors} | |
| 1731 | collisionDetection={projectCollisionDetection} | |
| 1732 | modifiers={[restrictToVerticalAxis, restrictToFirstScrollableAncestor]} | |
| 1733 | onDragStart={handleProjectDragStart} | |
| 1734 | onDragEnd={handleProjectDragEnd} | |
| 1735 | onDragCancel={handleProjectDragCancel} | |
| 1734 | 1736 | > |
| 1735 | <FolderIcon className="size-3.5" /> | |
| 1736 | {isPickingFolder ? "Picking folder..." : "Browse for folder"} | |
| 1737 | </button> | |
| 1737 | <SidebarMenu> | |
| 1738 | <SortableContext | |
| 1739 | items={sortedProjects.map((project) => project.id)} | |
| 1740 | strategy={verticalListSortingStrategy} | |
| 1741 | > | |
| 1742 | {sortedProjects.map((project) => ( | |
| 1743 | <SortableProjectItem key={project.id} projectId={project.id}> | |
| 1744 | {(dragHandleProps) => renderProjectItem(project, dragHandleProps)} | |
| 1745 | </SortableProjectItem> | |
| 1746 | ))} | |
| 1747 | </SortableContext> | |
| 1748 | </SidebarMenu> | |
| 1749 | </DndContext> | |
| 1750 | ) : ( | |
| 1751 | <SidebarMenu ref={attachProjectListAutoAnimateRef}> | |
| 1752 | {sortedProjects.map((project) => ( | |
| 1753 | <SidebarMenuItem key={project.id} className="rounded-md"> | |
| 1754 | {renderProjectItem(project, null)} | |
| 1755 | </SidebarMenuItem> | |
| 1756 | ))} | |
| 1757 | </SidebarMenu> | |
| 1738 | 1758 | )} |
| 1739 | <div className="flex gap-1.5"> | |
| 1740 | <input | |
| 1741 | ref={addProjectInputRef} | |
| 1742 | className={`min-w-0 flex-1 rounded-md border bg-secondary px-2 py-1 font-mono text-xs text-foreground placeholder:text-muted-foreground/40 focus:outline-none ${ | |
| 1743 | addProjectError | |
| 1744 | ? "border-red-500/70 focus:border-red-500" | |
| 1745 | : "border-border focus:border-ring" | |
| 1746 | }`} | |
| 1747 | placeholder="/path/to/project" | |
| 1748 | value={newCwd} | |
| 1749 | onChange={(event) => { | |
| 1750 | setNewCwd(event.target.value); | |
| 1751 | setAddProjectError(null); | |
| 1752 | }} | |
| 1753 | onKeyDown={(event) => { | |
| 1754 | if (event.key === "Enter") handleAddProject(); | |
| 1755 | if (event.key === "Escape") { | |
| 1756 | setAddingProject(false); | |
| 1757 | setAddProjectError(null); | |
| 1758 | } | |
| 1759 | }} | |
| 1760 | autoFocus | |
| 1761 | /> | |
| 1762 | <button | |
| 1763 | type="button" | |
| 1764 | className="shrink-0 rounded-md bg-primary px-2.5 py-1 text-xs font-medium text-primary-foreground transition-colors duration-150 hover:bg-primary/90 disabled:opacity-60" | |
| 1765 | onClick={handleAddProject} | |
| 1766 | disabled={!canAddProject} | |
| 1767 | > | |
| 1768 | {isAddingProject ? "Adding..." : "Add"} | |
| 1769 | </button> | |
| 1770 | </div> | |
| 1771 | {addProjectError && ( | |
| 1772 | <p className="mt-1 px-0.5 text-[11px] leading-tight text-red-400"> | |
| 1773 | {addProjectError} | |
| 1774 | </p> | |
| 1759 | ||
| 1760 | {projects.length === 0 && !shouldShowProjectPathEntry && ( | |
| 1761 | <div className="px-2 pt-4 text-center text-xs text-muted-foreground/60"> | |
| 1762 | No projects yet | |
| 1763 | </div> | |
| 1775 | 1764 | )} |
| 1776 | <div className="mt-1.5 px-0.5"> | |
| 1777 | <button | |
| 1778 | type="button" | |
| 1779 | className="text-[11px] text-muted-foreground/50 transition-colors hover:text-muted-foreground" | |
| 1780 | onClick={() => { | |
| 1781 | setAddingProject(false); | |
| 1782 | setAddProjectError(null); | |
| 1783 | }} | |
| 1784 | > | |
| 1785 | Cancel | |
| 1786 | </button> | |
| 1787 | </div> | |
| 1788 | </div> | |
| 1789 | )} | |
| 1790 | ||
| 1791 | {isManualProjectSorting ? ( | |
| 1792 | <DndContext | |
| 1793 | sensors={projectDnDSensors} | |
| 1794 | collisionDetection={projectCollisionDetection} | |
| 1795 | modifiers={[restrictToVerticalAxis, restrictToFirstScrollableAncestor]} | |
| 1796 | onDragStart={handleProjectDragStart} | |
| 1797 | onDragEnd={handleProjectDragEnd} | |
| 1798 | onDragCancel={handleProjectDragCancel} | |
| 1799 | > | |
| 1800 | <SidebarMenu> | |
| 1801 | <SortableContext | |
| 1802 | items={sortedProjects.map((project) => project.id)} | |
| 1803 | strategy={verticalListSortingStrategy} | |
| 1765 | </SidebarGroup> | |
| 1766 | </SidebarContent> | |
| 1767 | ||
| 1768 | <SidebarSeparator /> | |
| 1769 | <SidebarFooter className="p-2"> | |
| 1770 | <SidebarMenu> | |
| 1771 | <SidebarMenuItem> | |
| 1772 | <SidebarMenuButton | |
| 1773 | size="sm" | |
| 1774 | className="gap-2 px-2 py-1.5 text-muted-foreground/70 hover:bg-accent hover:text-foreground" | |
| 1775 | onClick={() => void navigate({ to: "/settings" })} | |
| 1804 | 1776 | > |
| 1805 | {sortedProjects.map((project) => ( | |
| 1806 | <SortableProjectItem key={project.id} projectId={project.id}> | |
| 1807 | {(dragHandleProps) => renderProjectItem(project, dragHandleProps)} | |
| 1808 | </SortableProjectItem> | |
| 1809 | ))} | |
| 1810 | </SortableContext> | |
| 1811 | </SidebarMenu> | |
| 1812 | </DndContext> | |
| 1813 | ) : ( | |
| 1814 | <SidebarMenu ref={attachProjectListAutoAnimateRef}> | |
| 1815 | {sortedProjects.map((project) => ( | |
| 1816 | <SidebarMenuItem key={project.id} className="rounded-md"> | |
| 1817 | {renderProjectItem(project, null)} | |
| 1818 | </SidebarMenuItem> | |
| 1819 | ))} | |
| 1777 | <SettingsIcon className="size-3.5" /> | |
| 1778 | <span className="text-xs">Settings</span> | |
| 1779 | </SidebarMenuButton> | |
| 1780 | </SidebarMenuItem> | |
| 1820 | 1781 | </SidebarMenu> |
| 1821 | )} | |
| 1822 | ||
| 1823 | {projects.length === 0 && !shouldShowProjectPathEntry && ( | |
| 1824 | <div className="px-2 pt-4 text-center text-xs text-muted-foreground/60"> | |
| 1825 | No projects yet | |
| 1826 | </div> | |
| 1827 | )} | |
| 1828 | </SidebarGroup> | |
| 1829 | </SidebarContent> | |
| 1830 | ||
| 1831 | <SidebarSeparator /> | |
| 1832 | <SidebarFooter className="p-2"> | |
| 1833 | <SidebarMenu> | |
| 1834 | <SidebarMenuItem> | |
| 1835 | {isOnSettings ? ( | |
| 1836 | <SidebarMenuButton | |
| 1837 | size="sm" | |
| 1838 | className="gap-2 px-2 py-1.5 text-muted-foreground/70 hover:bg-accent hover:text-foreground" | |
| 1839 | onClick={() => window.history.back()} | |
| 1840 | > | |
| 1841 | <ArrowLeftIcon className="size-3.5" /> | |
| 1842 | <span className="text-xs">Back</span> | |
| 1843 | </SidebarMenuButton> | |
| 1844 | ) : ( | |
| 1845 | <SidebarMenuButton | |
| 1846 | size="sm" | |
| 1847 | className="gap-2 px-2 py-1.5 text-muted-foreground/70 hover:bg-accent hover:text-foreground" | |
| 1848 | onClick={() => void navigate({ to: "/settings" })} | |
| 1849 | > | |
| 1850 | <SettingsIcon className="size-3.5" /> | |
| 1851 | <span className="text-xs">Settings</span> | |
| 1852 | </SidebarMenuButton> | |
| 1853 | )} | |
| 1854 | </SidebarMenuItem> | |
| 1855 | </SidebarMenu> | |
| 1856 | </SidebarFooter> | |
| 1782 | </SidebarFooter> | |
| 1783 | </> | |
| 1784 | )} | |
| 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 | 44 | btn.type = "button"; |
| 45 | 45 | btn.textContent = item.label; |
| 46 | 46 | const isDestructiveAction = item.destructive === true || item.id === "delete"; |
| 47 | btn.className = isDestructiveAction | |
| 48 | ? "flex w-full items-center gap-2 px-3 py-1.5 text-left text-[11px] text-destructive hover:bg-accent cursor-default" | |
| 49 | : "flex w-full items-center gap-2 px-3 py-1.5 text-left text-[11px] text-popover-foreground hover:bg-accent cursor-default"; | |
| 50 | btn.addEventListener("click", () => cleanup(item.id)); | |
| 47 | const isDisabled = item.disabled === true; | |
| 48 | btn.disabled = isDisabled; | |
| 49 | btn.className = isDisabled | |
| 50 | ? "flex w-full items-center gap-2 px-3 py-1.5 text-left text-[11px] text-muted-foreground/60 cursor-not-allowed" | |
| 51 | : isDestructiveAction | |
| 52 | ? "flex w-full items-center gap-2 px-3 py-1.5 text-left text-[11px] text-destructive hover:bg-accent cursor-default" | |
| 53 | : "flex w-full items-center gap-2 px-3 py-1.5 text-left text-[11px] text-popover-foreground hover:bg-accent cursor-default"; | |
| 54 | if (!isDisabled) { | |
| 55 | btn.addEventListener("click", () => cleanup(item.id)); | |
| 56 | } | |
| 51 | 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 | 202 | ): Partial<DeepMutable<ClientSettings>> { |
| 203 | 203 | const patch: Partial<DeepMutable<ClientSettings>> = {}; |
| 204 | 204 | |
| 205 | if (Predicate.isBoolean(legacySettings.confirmThreadArchive)) { | |
| 206 | patch.confirmThreadArchive = legacySettings.confirmThreadArchive; | |
| 207 | } | |
| 208 | ||
| 205 | 209 | if (Predicate.isBoolean(legacySettings.confirmThreadDelete)) { |
| 206 | 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 | 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 | 42 | export function createDebouncedStorage( |
| 27 | baseStorage: StateStorage, | |
| 43 | baseStorage: Partial<StateStorage> | null | undefined, | |
| 28 | 44 | debounceMs: number = 300, |
| 29 | 45 | ): DebouncedStorage { |
| 46 | const resolvedStorage = resolveStorage(baseStorage); | |
| 30 | 47 | const debouncedSetItem = new Debouncer( |
| 31 | 48 | (name: string, value: string) => { |
| 32 | baseStorage.setItem(name, value); | |
| 49 | resolvedStorage.setItem(name, value); | |
| 33 | 50 | }, |
| 34 | 51 | { wait: debounceMs }, |
| 35 | 52 | ); |
| 36 | 53 | |
| 37 | 54 | return { |
| 38 | getItem: (name) => baseStorage.getItem(name), | |
| 55 | getItem: (name) => resolvedStorage.getItem(name), | |
| 39 | 56 | setItem: (name, value) => { |
| 40 | 57 | debouncedSetItem.maybeExecute(name, value); |
| 41 | 58 | }, |
| 42 | 59 | removeItem: (name) => { |
| 43 | 60 | debouncedSetItem.cancel(); |
| 44 | baseStorage.removeItem(name); | |
| 61 | resolvedStorage.removeItem(name); | |
| 45 | 62 | }, |
| 46 | 63 | flush: () => { |
| 47 | 64 | debouncedSetItem.flush(); |
apps/web/src/routeTree.gen.ts+82-19| ... | ... | @@ -9,11 +9,18 @@ |
| 9 | 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 | 11 | import { Route as rootRouteImport } from './routes/__root' |
| 12 | import { Route as SettingsRouteImport } from './routes/settings' | |
| 12 | 13 | import { Route as ChatRouteImport } from './routes/_chat' |
| 13 | 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 | 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 | 24 | const ChatRoute = ChatRouteImport.update({ |
| 18 | 25 | id: '/_chat', |
| 19 | 26 | getParentRoute: () => rootRouteImport, |
| ... | ... | @@ -23,10 +30,15 @@ const ChatIndexRoute = ChatIndexRouteImport.update({ |
| 23 | 30 | path: '/', |
| 24 | 31 | getParentRoute: () => ChatRoute, |
| 25 | 32 | } as any) |
| 26 | const ChatSettingsRoute = ChatSettingsRouteImport.update({ | |
| 27 | id: '/settings', | |
| 28 | path: '/settings', | |
| 29 | getParentRoute: () => ChatRoute, | |
| 33 | const SettingsGeneralRoute = SettingsGeneralRouteImport.update({ | |
| 34 | id: '/general', | |
| 35 | path: '/general', | |
| 36 | getParentRoute: () => SettingsRoute, | |
| 37 | } as any) | |
| 38 | const SettingsArchivedRoute = SettingsArchivedRouteImport.update({ | |
| 39 | id: '/archived', | |
| 40 | path: '/archived', | |
| 41 | getParentRoute: () => SettingsRoute, | |
| 30 | 42 | } as any) |
| 31 | 43 | const ChatThreadIdRoute = ChatThreadIdRouteImport.update({ |
| 32 | 44 | id: '/$threadId', |
| ... | ... | @@ -36,35 +48,66 @@ const ChatThreadIdRoute = ChatThreadIdRouteImport.update({ |
| 36 | 48 | |
| 37 | 49 | export interface FileRoutesByFullPath { |
| 38 | 50 | '/': typeof ChatIndexRoute |
| 51 | '/settings': typeof SettingsRouteWithChildren | |
| 39 | 52 | '/$threadId': typeof ChatThreadIdRoute |
| 40 | '/settings': typeof ChatSettingsRoute | |
| 53 | '/settings/archived': typeof SettingsArchivedRoute | |
| 54 | '/settings/general': typeof SettingsGeneralRoute | |
| 41 | 55 | } |
| 42 | 56 | export interface FileRoutesByTo { |
| 57 | '/settings': typeof SettingsRouteWithChildren | |
| 43 | 58 | '/$threadId': typeof ChatThreadIdRoute |
| 44 | '/settings': typeof ChatSettingsRoute | |
| 59 | '/settings/archived': typeof SettingsArchivedRoute | |
| 60 | '/settings/general': typeof SettingsGeneralRoute | |
| 45 | 61 | '/': typeof ChatIndexRoute |
| 46 | 62 | } |
| 47 | 63 | export interface FileRoutesById { |
| 48 | 64 | __root__: typeof rootRouteImport |
| 49 | 65 | '/_chat': typeof ChatRouteWithChildren |
| 66 | '/settings': typeof SettingsRouteWithChildren | |
| 50 | 67 | '/_chat/$threadId': typeof ChatThreadIdRoute |
| 51 | '/_chat/settings': typeof ChatSettingsRoute | |
| 68 | '/settings/archived': typeof SettingsArchivedRoute | |
| 69 | '/settings/general': typeof SettingsGeneralRoute | |
| 52 | 70 | '/_chat/': typeof ChatIndexRoute |
| 53 | 71 | } |
| 54 | 72 | export interface FileRouteTypes { |
| 55 | 73 | fileRoutesByFullPath: FileRoutesByFullPath |
| 56 | fullPaths: '/' | '/$threadId' | '/settings' | |
| 74 | fullPaths: | |
| 75 | | '/' | |
| 76 | | '/settings' | |
| 77 | | '/$threadId' | |
| 78 | | '/settings/archived' | |
| 79 | | '/settings/general' | |
| 57 | 80 | fileRoutesByTo: FileRoutesByTo |
| 58 | to: '/$threadId' | '/settings' | '/' | |
| 59 | id: '__root__' | '/_chat' | '/_chat/$threadId' | '/_chat/settings' | '/_chat/' | |
| 81 | to: | |
| 82 | | '/settings' | |
| 83 | | '/$threadId' | |
| 84 | | '/settings/archived' | |
| 85 | | '/settings/general' | |
| 86 | | '/' | |
| 87 | id: | |
| 88 | | '__root__' | |
| 89 | | '/_chat' | |
| 90 | | '/settings' | |
| 91 | | '/_chat/$threadId' | |
| 92 | | '/settings/archived' | |
| 93 | | '/settings/general' | |
| 94 | | '/_chat/' | |
| 60 | 95 | fileRoutesById: FileRoutesById |
| 61 | 96 | } |
| 62 | 97 | export interface RootRouteChildren { |
| 63 | 98 | ChatRoute: typeof ChatRouteWithChildren |
| 99 | SettingsRoute: typeof SettingsRouteWithChildren | |
| 64 | 100 | } |
| 65 | 101 | |
| 66 | 102 | declare module '@tanstack/react-router' { |
| 67 | 103 | interface FileRoutesByPath { |
| 104 | '/settings': { | |
| 105 | id: '/settings' | |
| 106 | path: '/settings' | |
| 107 | fullPath: '/settings' | |
| 108 | preLoaderRoute: typeof SettingsRouteImport | |
| 109 | parentRoute: typeof rootRouteImport | |
| 110 | } | |
| 68 | 111 | '/_chat': { |
| 69 | 112 | id: '/_chat' |
| 70 | 113 | path: '' |
| ... | ... | @@ -79,12 +122,19 @@ declare module '@tanstack/react-router' { |
| 79 | 122 | preLoaderRoute: typeof ChatIndexRouteImport |
| 80 | 123 | parentRoute: typeof ChatRoute |
| 81 | 124 | } |
| 82 | '/_chat/settings': { | |
| 83 | id: '/_chat/settings' | |
| 84 | path: '/settings' | |
| 85 | fullPath: '/settings' | |
| 86 | preLoaderRoute: typeof ChatSettingsRouteImport | |
| 87 | parentRoute: typeof ChatRoute | |
| 125 | '/settings/general': { | |
| 126 | id: '/settings/general' | |
| 127 | path: '/general' | |
| 128 | fullPath: '/settings/general' | |
| 129 | preLoaderRoute: typeof SettingsGeneralRouteImport | |
| 130 | parentRoute: typeof SettingsRoute | |
| 131 | } | |
| 132 | '/settings/archived': { | |
| 133 | id: '/settings/archived' | |
| 134 | path: '/archived' | |
| 135 | fullPath: '/settings/archived' | |
| 136 | preLoaderRoute: typeof SettingsArchivedRouteImport | |
| 137 | parentRoute: typeof SettingsRoute | |
| 88 | 138 | } |
| 89 | 139 | '/_chat/$threadId': { |
| 90 | 140 | id: '/_chat/$threadId' |
| ... | ... | @@ -98,20 +148,33 @@ declare module '@tanstack/react-router' { |
| 98 | 148 | |
| 99 | 149 | interface ChatRouteChildren { |
| 100 | 150 | ChatThreadIdRoute: typeof ChatThreadIdRoute |
| 101 | ChatSettingsRoute: typeof ChatSettingsRoute | |
| 102 | 151 | ChatIndexRoute: typeof ChatIndexRoute |
| 103 | 152 | } |
| 104 | 153 | |
| 105 | 154 | const ChatRouteChildren: ChatRouteChildren = { |
| 106 | 155 | ChatThreadIdRoute: ChatThreadIdRoute, |
| 107 | ChatSettingsRoute: ChatSettingsRoute, | |
| 108 | 156 | ChatIndexRoute: ChatIndexRoute, |
| 109 | 157 | } |
| 110 | 158 | |
| 111 | 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 | 175 | const rootRouteChildren: RootRouteChildren = { |
| 114 | 176 | ChatRoute: ChatRouteWithChildren, |
| 177 | SettingsRoute: SettingsRouteWithChildren, | |
| 115 | 178 | } |
| 116 | 179 | export const routeTree = rootRouteImport |
| 117 | 180 | ._addFileChildren(rootRouteChildren) |
apps/web/src/routes/__root.tsx+4-1| ... | ... | @@ -11,6 +11,7 @@ import { QueryClient, useQueryClient } from "@tanstack/react-query"; |
| 11 | 11 | import { Throttler } from "@tanstack/react-pacer"; |
| 12 | 12 | |
| 13 | 13 | import { APP_DISPLAY_NAME } from "../branding"; |
| 14 | import { AppSidebarLayout } from "../components/AppSidebarLayout"; | |
| 14 | 15 | import { Button } from "../components/ui/button"; |
| 15 | 16 | import { AnchoredToastProvider, ToastProvider, toastManager } from "../components/ui/toast"; |
| 16 | 17 | import { resolveAndPersistPreferredEditor } from "../editorPreferences"; |
| ... | ... | @@ -54,7 +55,9 @@ function RootRouteView() { |
| 54 | 55 | <AnchoredToastProvider> |
| 55 | 56 | <EventRouter /> |
| 56 | 57 | <DesktopProjectBootstrap /> |
| 57 | <Outlet /> | |
| 58 | <AppSidebarLayout> | |
| 59 | <Outlet /> | |
| 60 | </AppSidebarLayout> | |
| 58 | 61 | </AnchoredToastProvider> |
| 59 | 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 | 1 | import { type ResolvedKeybindingsConfig } from "@t3tools/contracts"; |
| 2 | 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 | 4 | import { useEffect } from "react"; |
| 5 | 5 | |
| 6 | import ThreadSidebar from "../components/Sidebar"; | |
| 7 | 6 | import { useHandleNewThread } from "../hooks/useHandleNewThread"; |
| 8 | 7 | import { isTerminalFocused } from "../lib/terminalFocus"; |
| 9 | 8 | import { serverConfigQueryOptions } from "../lib/serverReactQuery"; |
| ... | ... | @@ -12,12 +11,8 @@ import { selectThreadTerminalState, useTerminalStateStore } from "../terminalSta |
| 12 | 11 | import { useThreadSelectionStore } from "../threadSelectionStore"; |
| 13 | 12 | import { resolveSidebarNewThreadEnvMode } from "~/components/Sidebar.logic"; |
| 14 | 13 | import { useSettings } from "~/hooks/useSettings"; |
| 15 | import { Sidebar, SidebarProvider, SidebarRail } from "~/components/ui/sidebar"; | |
| 16 | 14 | |
| 17 | 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 | 17 | function ChatRouteGlobalShortcuts() { |
| 23 | 18 | const clearSelection = useThreadSelectionStore((state) => state.clearSelection); |
| ... | ... | @@ -94,43 +89,11 @@ function ChatRouteGlobalShortcuts() { |
| 94 | 89 | } |
| 95 | 90 | |
| 96 | 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 | 92 | return ( |
| 116 | <SidebarProvider defaultOpen> | |
| 93 | <> | |
| 117 | 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 | 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 | 29 | proposedPlans: [], |
| 30 | 30 | error: null, |
| 31 | 31 | createdAt: "2026-02-13T00:00:00.000Z", |
| 32 | archivedAt: null, | |
| 32 | 33 | latestTurn: null, |
| 33 | 34 | branch: null, |
| 34 | 35 | worktreePath: null, |
| ... | ... | @@ -72,6 +73,7 @@ function makeReadModelThread(overrides: Partial<OrchestrationReadModel["threads" |
| 72 | 73 | latestTurn: null, |
| 73 | 74 | createdAt: "2026-02-27T00:00:00.000Z", |
| 74 | 75 | updatedAt: "2026-02-27T00:00:00.000Z", |
| 76 | archivedAt: null, | |
| 75 | 77 | deletedAt: null, |
| 76 | 78 | messages: [], |
| 77 | 79 | activities: [], |
| ... | ... | @@ -270,6 +272,21 @@ describe("store read model sync", () => { |
| 270 | 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 | 290 | it("preserves the current project order when syncing incoming read model updates", () => { |
| 274 | 291 | const project1 = ProjectId.makeUnsafe("project-1"); |
| 275 | 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 | 299 | })), |
| 300 | 300 | error: thread.session?.lastError ?? null, |
| 301 | 301 | createdAt: thread.createdAt, |
| 302 | archivedAt: thread.archivedAt, | |
| 302 | 303 | updatedAt: thread.updatedAt, |
| 303 | 304 | latestTurn: thread.latestTurn, |
| 304 | 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 | 7 | |
| 8 | 8 | describe("terminalStateStore actions", () => { |
| 9 | 9 | beforeEach(() => { |
| 10 | if (typeof localStorage !== "undefined") { | |
| 11 | localStorage.clear(); | |
| 12 | } | |
| 10 | useTerminalStateStore.persist.clearStorage(); | |
| 13 | 11 | useTerminalStateStore.setState({ terminalStateByThreadId: {} }); |
| 14 | 12 | }); |
| 15 | 13 |
apps/web/src/terminalStateStore.ts+6-1| ... | ... | @@ -8,6 +8,7 @@ |
| 8 | 8 | import type { ThreadId } from "@t3tools/contracts"; |
| 9 | 9 | import { create } from "zustand"; |
| 10 | 10 | import { createJSONStorage, persist } from "zustand/middleware"; |
| 11 | import { resolveStorage } from "./lib/storage"; | |
| 11 | 12 | import { |
| 12 | 13 | DEFAULT_THREAD_TERMINAL_HEIGHT, |
| 13 | 14 | DEFAULT_THREAD_TERMINAL_ID, |
| ... | ... | @@ -27,6 +28,10 @@ interface ThreadTerminalState { |
| 27 | 28 | |
| 28 | 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 | 35 | function normalizeTerminalIds(terminalIds: string[]): string[] { |
| 31 | 36 | const ids = [...new Set(terminalIds.map((id) => id.trim()).filter((id) => id.length > 0))]; |
| 32 | 37 | return ids.length > 0 ? ids : [DEFAULT_THREAD_TERMINAL_ID]; |
| ... | ... | @@ -542,7 +547,7 @@ export const useTerminalStateStore = create<TerminalStateStoreState>()( |
| 542 | 547 | { |
| 543 | 548 | name: TERMINAL_STATE_STORAGE_KEY, |
| 544 | 549 | version: 1, |
| 545 | storage: createJSONStorage(() => localStorage), | |
| 550 | storage: createJSONStorage(createTerminalStateStorage), | |
| 546 | 551 | partialize: (state) => ({ |
| 547 | 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 | 66 | const days = Math.floor(hours / 24); |
| 67 | 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 | 101 | proposedPlans: ProposedPlan[]; |
| 102 | 102 | error: string | null; |
| 103 | 103 | createdAt: string; |
| 104 | archivedAt: string | null; | |
| 104 | 105 | updatedAt?: string | undefined; |
| 105 | 106 | latestTurn: OrchestrationLatestTurn | null; |
| 106 | 107 | lastVisitedAt?: string | undefined; |
apps/web/src/worktreeCleanup.test.ts+1| ... | ... | @@ -23,6 +23,7 @@ function makeThread(overrides: Partial<Thread> = {}): Thread { |
| 23 | 23 | proposedPlans: [], |
| 24 | 24 | error: null, |
| 25 | 25 | createdAt: "2026-02-13T00:00:00.000Z", |
| 26 | archivedAt: null, | |
| 26 | 27 | latestTurn: null, |
| 27 | 28 | branch: null, |
| 28 | 29 | worktreePath: null, |
packages/contracts/src/ipc.ts+1| ... | ... | @@ -57,6 +57,7 @@ export interface ContextMenuItem<T extends string = string> { |
| 57 | 57 | id: T; |
| 58 | 58 | label: string; |
| 59 | 59 | destructive?: boolean; |
| 60 | disabled?: boolean; | |
| 60 | 61 | } |
| 61 | 62 | |
| 62 | 63 | export type DesktopUpdateStatus = |
packages/contracts/src/orchestration.test.ts+64| ... | ... | @@ -5,6 +5,8 @@ import { Effect, Schema } from "effect"; |
| 5 | 5 | import { |
| 6 | 6 | DEFAULT_PROVIDER_INTERACTION_MODE, |
| 7 | 7 | DEFAULT_RUNTIME_MODE, |
| 8 | OrchestrationCommand, | |
| 9 | OrchestrationEvent, | |
| 8 | 10 | OrchestrationGetTurnDiffInput, |
| 9 | 11 | OrchestrationLatestTurn, |
| 10 | 12 | ProjectCreatedPayload, |
| ... | ... | @@ -32,6 +34,8 @@ const decodeOrchestrationLatestTurn = Schema.decodeUnknownEffect(OrchestrationLa |
| 32 | 34 | const decodeOrchestrationProposedPlan = Schema.decodeUnknownEffect(OrchestrationProposedPlan); |
| 33 | 35 | const decodeOrchestrationSession = Schema.decodeUnknownEffect(OrchestrationSession); |
| 34 | 36 | const decodeThreadCreatedPayload = Schema.decodeUnknownEffect(ThreadCreatedPayload); |
| 37 | const decodeOrchestrationCommand = Schema.decodeUnknownEffect(OrchestrationCommand); | |
| 38 | const decodeOrchestrationEvent = Schema.decodeUnknownEffect(OrchestrationEvent); | |
| 35 | 39 | const decodeThreadMetaUpdatedPayload = Schema.decodeUnknownEffect(ThreadMetaUpdatedPayload); |
| 36 | 40 | |
| 37 | 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 | 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 | 294 | it.effect("accepts provider-scoped model options in thread.turn.start", () => |
| 231 | 295 | Effect.gen(function* () { |
| 232 | 296 | const parsed = yield* decodeThreadTurnStartCommand({ |
packages/contracts/src/orchestration.ts+40| ... | ... | @@ -283,6 +283,7 @@ export const OrchestrationThread = Schema.Struct({ |
| 283 | 283 | latestTurn: Schema.NullOr(OrchestrationLatestTurn), |
| 284 | 284 | createdAt: IsoDateTime, |
| 285 | 285 | updatedAt: IsoDateTime, |
| 286 | archivedAt: Schema.NullOr(IsoDateTime).pipe(Schema.withDecodingDefault(() => null)), | |
| 286 | 287 | deletedAt: Schema.NullOr(IsoDateTime), |
| 287 | 288 | messages: Schema.Array(OrchestrationMessage), |
| 288 | 289 | proposedPlans: Schema.Array(OrchestrationProposedPlan).pipe(Schema.withDecodingDefault(() => [])), |
| ... | ... | @@ -348,6 +349,18 @@ const ThreadDeleteCommand = Schema.Struct({ |
| 348 | 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 | 364 | const ThreadMetaUpdateCommand = Schema.Struct({ |
| 352 | 365 | type: Schema.Literal("thread.meta.update"), |
| 353 | 366 | commandId: CommandId, |
| ... | ... | @@ -457,6 +470,8 @@ const DispatchableClientOrchestrationCommand = Schema.Union([ |
| 457 | 470 | ProjectDeleteCommand, |
| 458 | 471 | ThreadCreateCommand, |
| 459 | 472 | ThreadDeleteCommand, |
| 473 | ThreadArchiveCommand, | |
| 474 | ThreadUnarchiveCommand, | |
| 460 | 475 | ThreadMetaUpdateCommand, |
| 461 | 476 | ThreadRuntimeModeSetCommand, |
| 462 | 477 | ThreadInteractionModeSetCommand, |
| ... | ... | @@ -476,6 +491,8 @@ export const ClientOrchestrationCommand = Schema.Union([ |
| 476 | 491 | ProjectDeleteCommand, |
| 477 | 492 | ThreadCreateCommand, |
| 478 | 493 | ThreadDeleteCommand, |
| 494 | ThreadArchiveCommand, | |
| 495 | ThreadUnarchiveCommand, | |
| 479 | 496 | ThreadMetaUpdateCommand, |
| 480 | 497 | ThreadRuntimeModeSetCommand, |
| 481 | 498 | ThreadInteractionModeSetCommand, |
| ... | ... | @@ -576,6 +593,8 @@ export const OrchestrationEventType = Schema.Literals([ |
| 576 | 593 | "project.deleted", |
| 577 | 594 | "thread.created", |
| 578 | 595 | "thread.deleted", |
| 596 | "thread.archived", | |
| 597 | "thread.unarchived", | |
| 579 | 598 | "thread.meta-updated", |
| 580 | 599 | "thread.runtime-mode-set", |
| 581 | 600 | "thread.interaction-mode-set", |
| ... | ... | @@ -642,6 +661,17 @@ export const ThreadDeletedPayload = Schema.Struct({ |
| 642 | 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 | 675 | export const ThreadMetaUpdatedPayload = Schema.Struct({ |
| 646 | 676 | threadId: ThreadId, |
| 647 | 677 | title: Schema.optional(TrimmedNonEmptyString), |
| ... | ... | @@ -799,6 +829,16 @@ export const OrchestrationEvent = Schema.Union([ |
| 799 | 829 | type: Schema.Literal("thread.deleted"), |
| 800 | 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 | 842 | Schema.Struct({ |
| 803 | 843 | ...EventBaseFields, |
| 804 | 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 | 24 | export const DEFAULT_SIDEBAR_THREAD_SORT_ORDER: SidebarThreadSortOrder = "updated_at"; |
| 25 | 25 | |
| 26 | 26 | export const ClientSettingsSchema = Schema.Struct({ |
| 27 | confirmThreadArchive: Schema.Boolean.pipe(Schema.withDecodingDefault(() => false)), | |
| 27 | 28 | confirmThreadDelete: Schema.Boolean.pipe(Schema.withDecodingDefault(() => true)), |
| 28 | 29 | diffWordWrap: Schema.Boolean.pipe(Schema.withDecodingDefault(() => false)), |
| 29 | 30 | sidebarProjectSortOrder: SidebarProjectSortOrder.pipe( |