Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ function makeSnapshot(input: {
id: input.threadId,
projectId: input.projectId,
title: "Thread",
pinned: false,
model: "gpt-5-codex",
interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE,
runtimeMode: "full-access",
Expand Down
1 change: 1 addition & 0 deletions apps/server/src/keybindings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@ export const DEFAULT_KEYBINDINGS: ReadonlyArray<KeybindingRule> = [
{ key: "mod+n", command: "chat.new", when: "!terminalFocus" },
{ key: "mod+shift+o", command: "chat.new", when: "!terminalFocus" },
{ key: "mod+shift+n", command: "chat.newLocal", when: "!terminalFocus" },
{ key: "mod+shift+p", command: "thread.togglePinned", when: "!terminalFocus" },
{ key: "mod+o", command: "editor.openFavorite" },
];

Expand Down
2 changes: 2 additions & 0 deletions apps/server/src/orchestration/Layers/ProjectionPipeline.ts
Original file line number Diff line number Diff line change
Expand Up @@ -420,6 +420,7 @@ const makeOrchestrationProjectionPipeline = Effect.gen(function* () {
threadId: event.payload.threadId,
projectId: event.payload.projectId,
title: event.payload.title,
pinned: event.payload.pinned,
model: event.payload.model,
runtimeMode: event.payload.runtimeMode,
interactionMode: event.payload.interactionMode,
Expand All @@ -442,6 +443,7 @@ const makeOrchestrationProjectionPipeline = Effect.gen(function* () {
yield* projectionThreadRepository.upsert({
...existingRow.value,
...(event.payload.title !== undefined ? { title: event.payload.title } : {}),
...(event.payload.pinned !== undefined ? { pinned: event.payload.pinned } : {}),
...(event.payload.model !== undefined ? { model: event.payload.model } : {}),
...(event.payload.branch !== undefined ? { branch: event.payload.branch } : {}),
...(event.payload.worktreePath !== undefined
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -226,6 +226,7 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => {
id: ThreadId.makeUnsafe("thread-1"),
projectId: asProjectId("project-1"),
title: "Thread 1",
pinned: false,
model: "gpt-5-codex",
interactionMode: "default",
runtimeMode: "full-access",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,11 @@ const ProjectionThreadMessageDbRowSchema = ProjectionThreadMessage.mapFields(
}),
);
const ProjectionThreadProposedPlanDbRowSchema = ProjectionThreadProposedPlan;
const ProjectionThreadDbRowSchema = ProjectionThread;
const ProjectionThreadDbRowSchema = ProjectionThread.mapFields(
Struct.assign({
pinned: Schema.Number,
}),
);
const ProjectionThreadActivityDbRowSchema = ProjectionThreadActivity.mapFields(
Struct.assign({
payload: Schema.fromJsonString(Schema.Unknown),
Expand Down Expand Up @@ -156,6 +160,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {
thread_id AS "threadId",
project_id AS "projectId",
title,
pinned,
model,
runtime_mode AS "runtimeMode",
interaction_mode AS "interactionMode",
Expand Down Expand Up @@ -528,6 +533,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {
id: row.threadId,
projectId: row.projectId,
title: row.title,
pinned: row.pinned === 1,
model: row.model,
runtimeMode: row.runtimeMode,
interactionMode: row.interactionMode,
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 @@ -50,6 +50,7 @@ const readModel: OrchestrationReadModel = {
id: ThreadId.makeUnsafe("thread-1"),
projectId: ProjectId.makeUnsafe("project-a"),
title: "Thread A",
pinned: false,
model: "gpt-5-codex",
interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE,
runtimeMode: "full-access",
Expand All @@ -69,6 +70,7 @@ const readModel: OrchestrationReadModel = {
id: ThreadId.makeUnsafe("thread-2"),
projectId: ProjectId.makeUnsafe("project-b"),
title: "Thread B",
pinned: false,
model: "gpt-5-codex",
interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE,
runtimeMode: "full-access",
Expand Down
3 changes: 3 additions & 0 deletions apps/server/src/orchestration/decider.projectScripts.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,7 @@ describe("decider project scripts", () => {
threadId: ThreadId.makeUnsafe("thread-1"),
projectId: asProjectId("project-1"),
title: "Thread",
pinned: false,
model: "gpt-5-codex",
interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE,
runtimeMode: "approval-required",
Expand Down Expand Up @@ -243,6 +244,7 @@ describe("decider project scripts", () => {
threadId: ThreadId.makeUnsafe("thread-1"),
projectId: asProjectId("project-1"),
title: "Thread",
pinned: false,
model: "gpt-5-codex",
interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE,
runtimeMode: "full-access",
Expand Down Expand Up @@ -322,6 +324,7 @@ describe("decider project scripts", () => {
threadId: ThreadId.makeUnsafe("thread-1"),
projectId: asProjectId("project-1"),
title: "Thread",
pinned: false,
model: "gpt-5-codex",
interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE,
runtimeMode: "approval-required",
Expand Down
2 changes: 2 additions & 0 deletions apps/server/src/orchestration/decider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -156,6 +156,7 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand"
threadId: command.threadId,
projectId: command.projectId,
title: command.title,
pinned: false,
model: command.model,
runtimeMode: command.runtimeMode,
interactionMode: command.interactionMode,
Expand Down Expand Up @@ -207,6 +208,7 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand"
payload: {
threadId: command.threadId,
...(command.title !== undefined ? { title: command.title } : {}),
...(command.pinned !== undefined ? { pinned: command.pinned } : {}),
...(command.model !== undefined ? { model: command.model } : {}),
...(command.branch !== undefined ? { branch: command.branch } : {}),
...(command.worktreePath !== undefined ? { worktreePath: command.worktreePath } : {}),
Expand Down
54 changes: 54 additions & 0 deletions apps/server/src/orchestration/projector.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@ describe("orchestration projector", () => {
id: "thread-1",
projectId: "project-1",
title: "demo",
pinned: false,
model: "gpt-5-codex",
runtimeMode: "full-access",
interactionMode: "default",
Expand Down Expand Up @@ -267,6 +268,59 @@ describe("orchestration projector", () => {
expect(afterUpdate.threads[0]?.updatedAt).toBe(updatedAt);
});

it("updates canonical thread pinned state from thread.meta-updated", async () => {
const createdAt = "2026-02-23T08:00:00.000Z";
const updatedAt = "2026-02-23T08:00:05.000Z";
const model = createEmptyReadModel(createdAt);

const afterCreate = await Effect.runPromise(
projectEvent(
model,
makeEvent({
sequence: 1,
type: "thread.created",
aggregateKind: "thread",
aggregateId: "thread-1",
occurredAt: createdAt,
commandId: "cmd-create",
payload: {
threadId: "thread-1",
projectId: "project-1",
title: "demo",
model: "gpt-5.3-codex",
runtimeMode: "full-access",
branch: null,
worktreePath: null,
createdAt,
updatedAt: createdAt,
},
}),
),
);

const afterUpdate = await Effect.runPromise(
projectEvent(
afterCreate,
makeEvent({
sequence: 2,
type: "thread.meta-updated",
aggregateKind: "thread",
aggregateId: "thread-1",
occurredAt: updatedAt,
commandId: "cmd-pin-thread",
payload: {
threadId: "thread-1",
pinned: true,
updatedAt,
},
}),
),
);

expect(afterUpdate.threads[0]?.pinned).toBe(true);
expect(afterUpdate.threads[0]?.updatedAt).toBe(updatedAt);
});

it("marks assistant messages completed with non-streaming updates", async () => {
const createdAt = "2026-02-23T09:00:00.000Z";
const deltaAt = "2026-02-23T09:00:01.000Z";
Expand Down
2 changes: 2 additions & 0 deletions apps/server/src/orchestration/projector.ts
Original file line number Diff line number Diff line change
Expand Up @@ -252,6 +252,7 @@ export function projectEvent(
id: payload.threadId,
projectId: payload.projectId,
title: payload.title,
pinned: payload.pinned,
model: payload.model,
runtimeMode: payload.runtimeMode,
interactionMode: payload.interactionMode,
Expand Down Expand Up @@ -295,6 +296,7 @@ export function projectEvent(
...nextBase,
threads: updateThread(nextBase.threads, payload.threadId, {
...(payload.title !== undefined ? { title: payload.title } : {}),
...(payload.pinned !== undefined ? { pinned: payload.pinned } : {}),
...(payload.model !== undefined ? { model: payload.model } : {}),
...(payload.branch !== undefined ? { branch: payload.branch } : {}),
...(payload.worktreePath !== undefined ? { worktreePath: payload.worktreePath } : {}),
Expand Down
47 changes: 44 additions & 3 deletions apps/server/src/persistence/Layers/ProjectionThreads.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import * as SqlClient from "effect/unstable/sql/SqlClient";
import * as SqlSchema from "effect/unstable/sql/SqlSchema";
import { Effect, Layer } from "effect";
import { Effect, Layer, Option, Schema } from "effect";

import { toPersistenceSqlError } from "../Errors.ts";
import {
Expand All @@ -12,6 +12,40 @@ import {
type ProjectionThreadRepositoryShape,
} from "../Services/ProjectionThreads.ts";

const ProjectionThreadDbRowSchema = Schema.Struct({
threadId: ProjectionThread.fields.threadId,
projectId: ProjectionThread.fields.projectId,
title: ProjectionThread.fields.title,
pinned: Schema.Number,
model: ProjectionThread.fields.model,
runtimeMode: ProjectionThread.fields.runtimeMode,
interactionMode: ProjectionThread.fields.interactionMode,
branch: ProjectionThread.fields.branch,
worktreePath: ProjectionThread.fields.worktreePath,
latestTurnId: ProjectionThread.fields.latestTurnId,
createdAt: ProjectionThread.fields.createdAt,
updatedAt: ProjectionThread.fields.updatedAt,
deletedAt: ProjectionThread.fields.deletedAt,
});

function normalizeProjectionThreadRow(row: Schema.Schema.Type<typeof ProjectionThreadDbRowSchema>) {
return {
threadId: row.threadId,
projectId: row.projectId,
title: row.title,
pinned: row.pinned === 1,
model: row.model,
runtimeMode: row.runtimeMode,
interactionMode: row.interactionMode,
branch: row.branch,
worktreePath: row.worktreePath,
latestTurnId: row.latestTurnId,
createdAt: row.createdAt,
updatedAt: row.updatedAt,
deletedAt: row.deletedAt,
} satisfies ProjectionThread;
}

const makeProjectionThreadRepository = Effect.gen(function* () {
const sql = yield* SqlClient.SqlClient;

Expand All @@ -23,6 +57,7 @@ const makeProjectionThreadRepository = Effect.gen(function* () {
thread_id,
project_id,
title,
pinned,
model,
runtime_mode,
interaction_mode,
Expand All @@ -37,6 +72,7 @@ const makeProjectionThreadRepository = Effect.gen(function* () {
${row.threadId},
${row.projectId},
${row.title},
${row.pinned ? 1 : 0},
${row.model},
${row.runtimeMode},
${row.interactionMode},
Expand All @@ -51,6 +87,7 @@ const makeProjectionThreadRepository = Effect.gen(function* () {
DO UPDATE SET
project_id = excluded.project_id,
title = excluded.title,
pinned = excluded.pinned,
model = excluded.model,
runtime_mode = excluded.runtime_mode,
interaction_mode = excluded.interaction_mode,
Expand All @@ -65,13 +102,14 @@ const makeProjectionThreadRepository = Effect.gen(function* () {

const getProjectionThreadRow = SqlSchema.findOneOption({
Request: GetProjectionThreadInput,
Result: ProjectionThread,
Result: ProjectionThreadDbRowSchema,
execute: ({ threadId }) =>
sql`
SELECT
thread_id AS "threadId",
project_id AS "projectId",
title,
pinned,
model,
runtime_mode AS "runtimeMode",
interaction_mode AS "interactionMode",
Expand All @@ -88,13 +126,14 @@ const makeProjectionThreadRepository = Effect.gen(function* () {

const listProjectionThreadRows = SqlSchema.findAll({
Request: ListProjectionThreadsByProjectInput,
Result: ProjectionThread,
Result: ProjectionThreadDbRowSchema,
execute: ({ projectId }) =>
sql`
SELECT
thread_id AS "threadId",
project_id AS "projectId",
title,
pinned,
model,
runtime_mode AS "runtimeMode",
interaction_mode AS "interactionMode",
Expand Down Expand Up @@ -127,11 +166,13 @@ const makeProjectionThreadRepository = Effect.gen(function* () {
const getById: ProjectionThreadRepositoryShape["getById"] = (input) =>
getProjectionThreadRow(input).pipe(
Effect.mapError(toPersistenceSqlError("ProjectionThreadRepository.getById:query")),
Effect.map((row) => row.pipe(Option.map(normalizeProjectionThreadRow))),
);

const listByProjectId: ProjectionThreadRepositoryShape["listByProjectId"] = (input) =>
listProjectionThreadRows(input).pipe(
Effect.mapError(toPersistenceSqlError("ProjectionThreadRepository.listByProjectId:query")),
Effect.map((rows) => rows.map(normalizeProjectionThreadRow)),
);

const deleteById: ProjectionThreadRepositoryShape["deleteById"] = (input) =>
Expand Down
2 changes: 2 additions & 0 deletions apps/server/src/persistence/Migrations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import Migration0010 from "./Migrations/010_ProjectionThreadsRuntimeMode.ts";
import Migration0011 from "./Migrations/011_OrchestrationThreadCreatedRuntimeMode.ts";
import Migration0012 from "./Migrations/012_ProjectionThreadsInteractionMode.ts";
import Migration0013 from "./Migrations/013_ProjectionThreadProposedPlans.ts";
import Migration0014 from "./Migrations/014_ProjectionThreadsPinned.ts";
import { Effect } from "effect";

/**
Expand All @@ -51,6 +52,7 @@ const loader = Migrator.fromRecord({
"11_OrchestrationThreadCreatedRuntimeMode": Migration0011,
"12_ProjectionThreadsInteractionMode": Migration0012,
"13_ProjectionThreadProposedPlans": Migration0013,
"14_ProjectionThreadsPinned": Migration0014,
});

/**
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import * as Effect from "effect/Effect";
import * as SqlClient from "effect/unstable/sql/SqlClient";

export default Effect.gen(function* () {
const sql = yield* SqlClient.SqlClient;

yield* sql`
ALTER TABLE projection_threads
ADD COLUMN pinned INTEGER NOT NULL DEFAULT 0
`;
});
1 change: 1 addition & 0 deletions apps/server/src/persistence/Services/ProjectionThreads.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ export const ProjectionThread = Schema.Struct({
threadId: ThreadId,
projectId: ProjectId,
title: Schema.String,
pinned: Schema.Boolean,
model: Schema.String,
runtimeMode: RuntimeMode,
interactionMode: ProviderInteractionMode,
Expand Down
2 changes: 2 additions & 0 deletions apps/web/src/components/ChatView.browser.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -209,6 +209,7 @@ function createSnapshotForTargetUser(options: {
id: THREAD_ID,
projectId: PROJECT_ID,
title: "Browser test thread",
pinned: false,
model: "gpt-5",
interactionMode: "default",
runtimeMode: "full-access",
Expand Down Expand Up @@ -263,6 +264,7 @@ function addThreadToSnapshot(
id: threadId,
projectId: PROJECT_ID,
title: "New thread",
pinned: false,
model: "gpt-5",
interactionMode: "default",
runtimeMode: "full-access",
Expand Down
1 change: 1 addition & 0 deletions apps/web/src/components/ChatView.logic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ export function buildLocalDraftThread(
codexThreadId: null,
projectId: draftThread.projectId,
title: "New thread",
pinned: false,
model: fallbackModel,
runtimeMode: draftThread.runtimeMode,
interactionMode: draftThread.interactionMode,
Expand Down
Loading
Loading