Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
e68c0ec
Add thread archiving and settings navigation
shivamhwp Mar 24, 2026
f9d4443
Merge branch 'main' into archive-settings-overhaul
shivamhwp Mar 24, 2026
ef8f139
Use updated composer draft clearing action
shivamhwp Mar 24, 2026
5fa2d5a
Remove settings about page and improve archive controls
shivamhwp Mar 24, 2026
53883a6
Disable archive for running threads
shivamhwp Mar 24, 2026
9fa601d
Tighten running-thread checks before archiving
shivamhwp Mar 24, 2026
b18552e
Remount settings pane after restoring defaults
shivamhwp Mar 24, 2026
ab96571
Make settings outlet a flex column container
shivamhwp Mar 24, 2026
ff77559
icons alignment fix + settings ui fix + archive ui fix.
shivamhwp Mar 25, 2026
55b966f
Merge upstream/main into archive-settings-overhaul
shivamhwp Mar 25, 2026
431c8ad
Add thread archiving controls and settings
shivamhwp Mar 25, 2026
ac0943a
Format sidebar component
shivamhwp Mar 25, 2026
898a188
Harden terminal and settings storage persistence
shivamhwp Mar 25, 2026
c3db4b4
Add diff line wrapping setting
shivamhwp Mar 25, 2026
bfd7086
Add archived thread index and shared storage fallback
shivamhwp Mar 25, 2026
cb2d4e0
Merge branch 'main' into archive-settings-overhaul
shivamhwp Mar 25, 2026
7a3ed99
Move thread delete fallback into shared actions
shivamhwp Mar 25, 2026
83362cf
Add Claude-backed git text generation
shivamhwp Mar 25, 2026
56ad20a
Add settings return-to navigation and migration
shivamhwp Mar 25, 2026
153c63b
Backfill missing project default model selection
shivamhwp Mar 25, 2026
eeebb57
Remove settings navigation helpers
shivamhwp Mar 25, 2026
04f6ade
Merge branch 'main' into archive-settings-overhaul
shivamhwp Mar 26, 2026
f5643e7
Regenerate route tree types
shivamhwp Mar 26, 2026
8c8eb79
Merge branch 'main' into archive-settings-overhaul
shivamhwp Mar 26, 2026
a3b9af5
Overhaul archived threads settings
juliusmarminge Mar 27, 2026
4feddb6
Fix settings routing and remove duplicate migration helpers
shivamhwp Mar 27, 2026
3226768
Prevent sidebar from focusing archived threads
shivamhwp Mar 27, 2026
573b36b
Merge branch 'main' into archive-settings-overhaul
shivamhwp Mar 27, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions apps/desktop/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1141,6 +1141,7 @@ function registerIpcHandlers(): void {
id: item.id,
label: item.label,
destructive: item.destructive === true,
disabled: item.disabled === true,
}));
if (normalizedItems.length === 0) {
return null;
Expand Down Expand Up @@ -1171,6 +1172,7 @@ function registerIpcHandlers(): void {
}
const itemOption: MenuItemConstructorOptions = {
label: item.label,
enabled: !item.disabled,
click: () => resolve(item.id),
};
if (item.destructive) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ function makeSnapshot(input: {
},
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
archivedAt: null,
deletedAt: null,
messages: [],
activities: [],
Expand Down
67 changes: 67 additions & 0 deletions apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,73 @@ describe("OrchestrationEngine", () => {
await system.dispose();
});

it("archives and unarchives threads through orchestration commands", async () => {
const system = await createOrchestrationSystem();
const { engine } = system;
const createdAt = now();

await system.run(
engine.dispatch({
type: "project.create",
commandId: CommandId.makeUnsafe("cmd-project-archive-create"),
projectId: asProjectId("project-archive"),
title: "Project Archive",
workspaceRoot: "/tmp/project-archive",
defaultModelSelection: {
provider: "codex",
model: "gpt-5-codex",
},
createdAt,
}),
);
await system.run(
engine.dispatch({
type: "thread.create",
commandId: CommandId.makeUnsafe("cmd-thread-archive-create"),
threadId: ThreadId.makeUnsafe("thread-archive"),
projectId: asProjectId("project-archive"),
title: "Archive me",
modelSelection: {
provider: "codex",
model: "gpt-5-codex",
},
interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE,
runtimeMode: "full-access",
branch: null,
worktreePath: null,
createdAt,
}),
);

await system.run(
engine.dispatch({
type: "thread.archive",
commandId: CommandId.makeUnsafe("cmd-thread-archive"),
threadId: ThreadId.makeUnsafe("thread-archive"),
}),
);
expect(
(await system.run(engine.getReadModel())).threads.find(
(thread) => thread.id === "thread-archive",
)?.archivedAt,
).not.toBeNull();

await system.run(
engine.dispatch({
type: "thread.unarchive",
commandId: CommandId.makeUnsafe("cmd-thread-unarchive"),
threadId: ThreadId.makeUnsafe("thread-archive"),
}),
);
expect(
(await system.run(engine.getReadModel())).threads.find(
(thread) => thread.id === "thread-archive",
)?.archivedAt,
).toBeNull();

await system.dispose();
});

it("replays append-only events from sequence", async () => {
const system = await createOrchestrationSystem();
const { engine } = system;
Expand Down
31 changes: 31 additions & 0 deletions apps/server/src/orchestration/Layers/ProjectionPipeline.ts
Original file line number Diff line number Diff line change
Expand Up @@ -428,10 +428,41 @@ const makeOrchestrationProjectionPipeline = Effect.gen(function* () {
latestTurnId: null,
createdAt: event.payload.createdAt,
updatedAt: event.payload.updatedAt,
archivedAt: null,
deletedAt: null,
});
return;

case "thread.archived": {
const existingRow = yield* projectionThreadRepository.getById({
threadId: event.payload.threadId,
});
if (Option.isNone(existingRow)) {
return;
}
yield* projectionThreadRepository.upsert({
...existingRow.value,
archivedAt: event.payload.archivedAt,
updatedAt: event.payload.updatedAt,
});
return;
}

case "thread.unarchived": {
const existingRow = yield* projectionThreadRepository.getById({
threadId: event.payload.threadId,
});
if (Option.isNone(existingRow)) {
return;
}
yield* projectionThreadRepository.upsert({
...existingRow.value,
archivedAt: null,
updatedAt: event.payload.updatedAt,
});
return;
}

case "thread.meta-updated": {
const existingRow = yield* projectionThreadRepository.getById({
threadId: event.payload.threadId,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -279,6 +279,7 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => {
},
createdAt: "2026-02-24T00:00:02.000Z",
updatedAt: "2026-02-24T00:00:03.000Z",
archivedAt: null,
deletedAt: null,
messages: [
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -174,6 +174,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {
latest_turn_id AS "latestTurnId",
created_at AS "createdAt",
updated_at AS "updatedAt",
archived_at AS "archivedAt",
deleted_at AS "deletedAt"
FROM projection_threads
ORDER BY created_at ASC, thread_id ASC
Expand Down Expand Up @@ -560,6 +561,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {
latestTurn: latestTurnByThread.get(row.threadId) ?? null,
createdAt: row.createdAt,
updatedAt: row.updatedAt,
archivedAt: row.archivedAt,
deletedAt: row.deletedAt,
messages: messagesByThread.get(row.threadId) ?? [],
proposedPlans: proposedPlansByThread.get(row.threadId) ?? [],
Expand Down
4 changes: 4 additions & 0 deletions apps/server/src/orchestration/Schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,12 @@ import {
ProjectMetaUpdatedPayload as ContractsProjectMetaUpdatedPayloadSchema,
ProjectDeletedPayload as ContractsProjectDeletedPayloadSchema,
ThreadCreatedPayload as ContractsThreadCreatedPayloadSchema,
ThreadArchivedPayload as ContractsThreadArchivedPayloadSchema,
ThreadMetaUpdatedPayload as ContractsThreadMetaUpdatedPayloadSchema,
ThreadRuntimeModeSetPayload as ContractsThreadRuntimeModeSetPayloadSchema,
ThreadInteractionModeSetPayload as ContractsThreadInteractionModeSetPayloadSchema,
ThreadDeletedPayload as ContractsThreadDeletedPayloadSchema,
ThreadUnarchivedPayload as ContractsThreadUnarchivedPayloadSchema,
ThreadMessageSentPayload as ContractsThreadMessageSentPayloadSchema,
ThreadProposedPlanUpsertedPayload as ContractsThreadProposedPlanUpsertedPayloadSchema,
ThreadSessionSetPayload as ContractsThreadSessionSetPayloadSchema,
Expand All @@ -26,10 +28,12 @@ export const ProjectMetaUpdatedPayload = ContractsProjectMetaUpdatedPayloadSchem
export const ProjectDeletedPayload = ContractsProjectDeletedPayloadSchema;

export const ThreadCreatedPayload = ContractsThreadCreatedPayloadSchema;
export const ThreadArchivedPayload = ContractsThreadArchivedPayloadSchema;
export const ThreadMetaUpdatedPayload = ContractsThreadMetaUpdatedPayloadSchema;
export const ThreadRuntimeModeSetPayload = ContractsThreadRuntimeModeSetPayloadSchema;
export const ThreadInteractionModeSetPayload = ContractsThreadInteractionModeSetPayloadSchema;
export const ThreadDeletedPayload = ContractsThreadDeletedPayloadSchema;
export const ThreadUnarchivedPayload = ContractsThreadUnarchivedPayloadSchema;

export const MessageSentPayloadSchema = ContractsThreadMessageSentPayloadSchema;
export const ThreadProposedPlanUpsertedPayload = ContractsThreadProposedPlanUpsertedPayloadSchema;
Expand Down
2 changes: 2 additions & 0 deletions apps/server/src/orchestration/commandInvariants.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@ const readModel: OrchestrationReadModel = {
worktreePath: null,
createdAt: now,
updatedAt: now,
archivedAt: null,
latestTurn: null,
messages: [],
session: null,
Expand All @@ -88,6 +89,7 @@ const readModel: OrchestrationReadModel = {
worktreePath: null,
createdAt: now,
updatedAt: now,
archivedAt: null,
latestTurn: null,
messages: [],
session: null,
Expand Down
38 changes: 38 additions & 0 deletions apps/server/src/orchestration/commandInvariants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,44 @@ export function requireThread(input: {
);
}

export function requireThreadArchived(input: {
readonly readModel: OrchestrationReadModel;
readonly command: OrchestrationCommand;
readonly threadId: ThreadId;
}): Effect.Effect<OrchestrationThread, OrchestrationCommandInvariantError> {
return requireThread(input).pipe(
Effect.flatMap((thread) =>
thread.archivedAt !== null
? Effect.succeed(thread)
: Effect.fail(
invariantError(
input.command.type,
`Thread '${input.threadId}' is not archived for command '${input.command.type}'.`,
),
),
),
);
}

export function requireThreadNotArchived(input: {
readonly readModel: OrchestrationReadModel;
readonly command: OrchestrationCommand;
readonly threadId: ThreadId;
}): Effect.Effect<OrchestrationThread, OrchestrationCommandInvariantError> {
return requireThread(input).pipe(
Effect.flatMap((thread) =>
thread.archivedAt === null
? Effect.succeed(thread)
: Effect.fail(
invariantError(
input.command.type,
`Thread '${input.threadId}' is already archived and cannot handle command '${input.command.type}'.`,
),
),
),
);
}

export function requireThreadAbsent(input: {
readonly readModel: OrchestrationReadModel;
readonly command: OrchestrationCommand;
Expand Down
47 changes: 47 additions & 0 deletions apps/server/src/orchestration/decider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,9 @@ import {
requireProject,
requireProjectAbsent,
requireThread,
requireThreadArchived,
requireThreadAbsent,
requireThreadNotArchived,
} from "./commandInvariants.ts";

const nowIso = () => new Date().toISOString();
Expand Down Expand Up @@ -190,6 +192,51 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand"
};
}

case "thread.archive": {
yield* requireThreadNotArchived({
readModel,
command,
threadId: command.threadId,
});
const occurredAt = nowIso();
return {
...withEventBase({
aggregateKind: "thread",
aggregateId: command.threadId,
occurredAt,
commandId: command.commandId,
}),
type: "thread.archived",
payload: {
threadId: command.threadId,
archivedAt: occurredAt,
updatedAt: occurredAt,
},
};
}

case "thread.unarchive": {
yield* requireThreadArchived({
readModel,
command,
threadId: command.threadId,
});
const occurredAt = nowIso();
return {
...withEventBase({
aggregateKind: "thread",
aggregateId: command.threadId,
occurredAt,
commandId: command.commandId,
}),
type: "thread.unarchived",
payload: {
threadId: command.threadId,
updatedAt: occurredAt,
},
};
}

case "thread.meta.update": {
yield* requireThread({
readModel,
Expand Down
73 changes: 73 additions & 0 deletions apps/server/src/orchestration/projector.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,7 @@ describe("orchestration projector", () => {
latestTurn: null,
createdAt: now,
updatedAt: now,
archivedAt: null,
deletedAt: null,
messages: [],
proposedPlans: [],
Expand Down Expand Up @@ -131,6 +132,78 @@ describe("orchestration projector", () => {
).rejects.toBeDefined();
});

it("applies thread.archived and thread.unarchived events", async () => {
const now = new Date().toISOString();
const later = new Date(Date.parse(now) + 1_000).toISOString();
const created = await Effect.runPromise(
projectEvent(
createEmptyReadModel(now),
makeEvent({
sequence: 1,
type: "thread.created",
aggregateKind: "thread",
aggregateId: "thread-1",
occurredAt: now,
commandId: "cmd-thread-create",
payload: {
threadId: "thread-1",
projectId: "project-1",
title: "demo",
modelSelection: {
provider: "codex",
model: "gpt-5-codex",
},
runtimeMode: "full-access",
interactionMode: "default",
branch: null,
worktreePath: null,
createdAt: now,
updatedAt: now,
},
}),
),
);

const archived = await Effect.runPromise(
projectEvent(
created,
makeEvent({
sequence: 2,
type: "thread.archived",
aggregateKind: "thread",
aggregateId: "thread-1",
occurredAt: later,
commandId: "cmd-thread-archive",
payload: {
threadId: "thread-1",
archivedAt: later,
updatedAt: later,
},
}),
),
);
expect(archived.threads[0]?.archivedAt).toBe(later);

const unarchived = await Effect.runPromise(
projectEvent(
archived,
makeEvent({
sequence: 3,
type: "thread.unarchived",
aggregateKind: "thread",
aggregateId: "thread-1",
occurredAt: later,
commandId: "cmd-thread-unarchive",
payload: {
threadId: "thread-1",
updatedAt: later,
},
}),
),
);
expect(unarchived.threads[0]?.archivedAt).toBeNull();
});

it("keeps projector forward-compatible for unhandled event types", async () => {
const now = new Date().toISOString();
const model = createEmptyReadModel(now);
Expand Down
Loading