feat(app): pin chats to the top of the sidebar (#1981)
* feat(app): pin projects to top of the sidebar Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(app): dedicated Pinned sidebar section with UX fixes Replaces the invisible float-in-place pinning with a Codex-style Pinned section at the top of the sidebar that hoists pinned chats and projects. - Pin-aware keyboard shortcut numbering (badges match visual order) - Pinned chats show their project name as subtitle (context after hoisting) - Pinned entries ordered most-recently-pinned first - Collapsible Pinned section (persisted) - Double-click guard on workspace pin toggle Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(app): move Pinned section above the Workspaces header Pinned now sits at the top of the scroll, above the "Workspaces" section header (which moves into the scroll below it), matching the market-standard sidebar layout. Chevron sits beside the "Pinned" label. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(app): reveal Pinned collapse chevron on hover only Extracts PinnedSectionHeader with its own hover state; the chevron hides until hover on web and stays visible on touch/compact. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(app): pin the active chat via keyboard shortcut and inline pin button Adds a ⌘⇧P / Ctrl+Shift+P shortcut that pins the selected chat, mirroring the worktree.archive action across actions/dispatcher/route-shortcut, gated by `selected && supportsWorkspacePinning`. Also surfaces an inline pin button on row hover (always-on for touch) alongside the kebab in the shared trailing overlay, swapping Pin/PinOff by pinned state. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * refactor(server): unify project and workspace pin handlers Collapses the two near-identical pin.set handlers into a shared handlePinSetRequest that owns control flow, error handling and the workspace-update fan-out; each caller only supplies its registry apply step and typed response payload. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(app): drop empty pinned project shells and empty Workspaces header Pinning every chat of a project hoisted them into Pinned but left an empty project header (plus its new-workspace ghost row) below, duplicating the project. Skip projects emptied by pinning while keeping genuinely empty projects. Also hide the "Workspaces" section header when no unpinned projects remain, so it no longer floats above nothing. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(protocol): correct workspacePinning COMPAT version to v0.1.105 Feature ships in 0.1.105 per the changelog, not the placeholder v0.1.103. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(app): hide redundant chat pin control inside pinned projects A pinned project already hoists all its chats to the top, so the per-chat pin was a no-op that just toggled a dead icon. Suppress the inline pin button, the kebab pin item, and the pin keyboard shortcut on chats whose project is pinned; they still work for chats in unpinned projects. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(app): hide project pin control on empty projects Project pin state is derived from workspace descriptors, so a project with no workspaces would set pinnedAt on the server but emit no descriptor update — the pin silently never surfaced and the user got no feedback. Offer the pin only once the project has a chat, matching how chat pins are suppressed inside already-pinned projects. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(app): drop collapsed Pinned rows from keyboard shortcut numbering When the Pinned section is collapsed its chats and projects are hidden, but the shortcut model kept counting them: visible unpinned rows showed off-by-N badge numbers and pressing a number key jumped to a hidden pinned chat. Exclude the pinned rows from numbering when Pinned is collapsed, matching what renders. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(app): hydrate pinned sidebar rows * fix(app): keep filtered workspace controls reachable * feat(app): scope sidebar pinning to chats, drop project pinning Per PR review: pinning whole projects overlapped with project reordering and mixed projects into the pinned list. Keep chat pinning only. - Remove the project.pin.set RPC, its server handler, the projectPinnedAt descriptor field, and the client setProjectPinned method. - Drop the project pin control, isProjectPinned reads, and the suppressPin plumbing (its only source — a pinned project — is gone). - Fold pin-aware shortcut ordering into SidebarModelProvider (the live shortcut owner) and delete the orphaned use-sidebar-shortcut-model hook, so pinned chats and their shortcut numbers share one projection. - Prune the now-unused project pin i18n keys across all locales. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * refactor(server): inline the pin handler into the workspace path With project pinning gone, handlePinSetRequest had a single caller. Fold it back into handleWorkspacePinSetRequest and drop the project/workspace generalization. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * revert: keep main's use-sidebar-shortcut-model untouched This hook (and its test) already live on main. The scope-down had deleted them, but that's out of scope for this PR — the pin-aware shortcut ordering lives in SidebarModelProvider now, so main's hook is left exactly as it was. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * chore(protocol): align workspacePinning COMPAT version in websocket-server Match the v0.1.105 version already corrected in messages.ts. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(app): reshape pinned sidebar updates * chore(app): remove stale pin button styles * refactor(app): simplify pinned workspace rows --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
Mohamed Boudra
parent
8cd83b3a91
commit
a1e81685a1
@@ -1673,6 +1673,8 @@ export class Session {
|
||||
return this.handleWorkspaceClearAttentionRequest(msg);
|
||||
case "workspace.title.set.request":
|
||||
return this.handleWorkspaceTitleSetRequest(msg.workspaceId, msg.title, msg.requestId);
|
||||
case "workspace.pin.set.request":
|
||||
return this.handleWorkspacePinSetRequest(msg.workspaceId, msg.pinned, msg.requestId);
|
||||
case "file_explorer_request":
|
||||
return this.workspaceFilesSession.handleFileExplorerRequest(msg);
|
||||
case "project_icon_request":
|
||||
@@ -2293,8 +2295,15 @@ export class Session {
|
||||
);
|
||||
|
||||
try {
|
||||
const existing = await this.workspaceRegistry.get(workspaceId);
|
||||
if (!existing) {
|
||||
const trimmed = title?.trim() ?? "";
|
||||
const nextTitle = trimmed.length === 0 ? null : trimmed;
|
||||
const updatedAt = new Date().toISOString();
|
||||
const updated = await this.workspaceRegistry.update(workspaceId, (existing) => ({
|
||||
...existing,
|
||||
title: nextTitle,
|
||||
updatedAt,
|
||||
}));
|
||||
if (!updated) {
|
||||
this.emit({
|
||||
type: "workspace.title.set.response",
|
||||
payload: {
|
||||
@@ -2308,15 +2317,6 @@ export class Session {
|
||||
return;
|
||||
}
|
||||
|
||||
const trimmed = title?.trim() ?? "";
|
||||
const nextTitle = trimmed.length === 0 ? null : trimmed;
|
||||
|
||||
await this.workspaceRegistry.upsert({
|
||||
...existing,
|
||||
title: nextTitle,
|
||||
updatedAt: new Date().toISOString(),
|
||||
});
|
||||
|
||||
this.emit({
|
||||
type: "workspace.title.set.response",
|
||||
payload: {
|
||||
@@ -2358,6 +2358,52 @@ export class Session {
|
||||
}
|
||||
}
|
||||
|
||||
private async handleWorkspacePinSetRequest(
|
||||
workspaceId: string,
|
||||
pinned: boolean,
|
||||
requestId: string,
|
||||
): Promise<void> {
|
||||
const logContext = { workspaceId, pinned, requestId };
|
||||
this.sessionLogger.info(logContext, "session: workspace.pin.set.request");
|
||||
const emitResponse = (accepted: boolean, pinnedAt: string | null, error: string | null) => {
|
||||
this.emit({
|
||||
type: "workspace.pin.set.response",
|
||||
payload: { requestId, workspaceId, accepted, pinnedAt, error },
|
||||
});
|
||||
};
|
||||
|
||||
try {
|
||||
const nextPinnedAt = pinned ? new Date().toISOString() : null;
|
||||
const updatedAt = new Date().toISOString();
|
||||
const updated = await this.workspaceRegistry.update(workspaceId, (existing) => ({
|
||||
...existing,
|
||||
pinnedAt: nextPinnedAt,
|
||||
updatedAt,
|
||||
}));
|
||||
if (!updated) {
|
||||
emitResponse(false, null, "Workspace not found");
|
||||
return;
|
||||
}
|
||||
emitResponse(true, nextPinnedAt, null);
|
||||
await this.emitWorkspaceUpdatesForWorkspaceIds([workspaceId], { skipReconcile: true });
|
||||
} catch (error) {
|
||||
this.sessionLogger.error(
|
||||
{ ...logContext, err: error },
|
||||
"session: workspace.pin.set.request error",
|
||||
);
|
||||
this.emit({
|
||||
type: "activity_log",
|
||||
payload: {
|
||||
id: uuidv4(),
|
||||
timestamp: new Date(),
|
||||
type: "error",
|
||||
content: `Failed to pin workspace: ${getErrorMessage(error)}`,
|
||||
},
|
||||
});
|
||||
emitResponse(false, null, getErrorMessageOr(error, "Failed to pin workspace"));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle text message to agent (with optional image attachments)
|
||||
*/
|
||||
@@ -3573,6 +3619,7 @@ export class Session {
|
||||
workspaceKind: workspace.kind,
|
||||
name: resolveWorkspaceDisplayName(workspace),
|
||||
title: workspace.title,
|
||||
pinnedAt: workspace.pinnedAt,
|
||||
archivingAt: null,
|
||||
status: "done",
|
||||
statusEnteredAt: null,
|
||||
@@ -3657,6 +3704,7 @@ export class Session {
|
||||
derivedDisplayName: result.worktree.branchName || result.workspace.displayName,
|
||||
}),
|
||||
title: result.workspace.title,
|
||||
pinnedAt: result.workspace.pinnedAt,
|
||||
archivingAt: null,
|
||||
status: "done",
|
||||
statusEnteredAt: result.workspace.createdAt,
|
||||
|
||||
@@ -119,6 +119,10 @@ interface SessionTestAccess {
|
||||
list(...args: unknown[]): Promise<unknown[]>;
|
||||
archive(workspaceId: string, archivedAt: string): Promise<void>;
|
||||
get(workspaceId: string): Promise<unknown>;
|
||||
update(
|
||||
workspaceId: string,
|
||||
updater: (record: PersistedWorkspaceRecord) => PersistedWorkspaceRecord,
|
||||
): Promise<unknown>;
|
||||
upsert(record: unknown): Promise<unknown>;
|
||||
};
|
||||
agentUpdates: AgentUpdatesService;
|
||||
@@ -558,7 +562,7 @@ function createSessionForWorkspaceTests(
|
||||
clearAgentAttention: async () => {},
|
||||
notifyAgentState: () => {},
|
||||
});
|
||||
const workspaceRegistry = options.workspaceRegistry ?? {
|
||||
const workspaceRegistry: SessionOptions["workspaceRegistry"] = options.workspaceRegistry ?? {
|
||||
initialize: async () => {},
|
||||
existsOnDisk: async () => true,
|
||||
list: async () => [
|
||||
@@ -584,6 +588,22 @@ function createSessionForWorkspaceTests(
|
||||
updatedAt: "2026-03-01T12:00:00.000Z",
|
||||
})
|
||||
: null,
|
||||
update: async (workspaceId, updater) => {
|
||||
if (workspaceId !== "ws-repo-running") {
|
||||
return null;
|
||||
}
|
||||
return updater(
|
||||
createPersistedWorkspaceRecord({
|
||||
workspaceId: "ws-repo-running",
|
||||
projectId: "proj-repo-running",
|
||||
cwd: REPO_CWD,
|
||||
kind: "directory",
|
||||
displayName: "repo",
|
||||
createdAt: "2026-03-01T12:00:00.000Z",
|
||||
updatedAt: "2026-03-01T12:00:00.000Z",
|
||||
}),
|
||||
);
|
||||
},
|
||||
upsert: async () => {},
|
||||
archive: async () => {},
|
||||
remove: async () => {},
|
||||
@@ -6569,9 +6589,12 @@ test("workspace.title.set.request stores the title and emits an updated descript
|
||||
session.projectRegistry.list = async () => Array.from(projects.values());
|
||||
session.workspaceRegistry.list = async () => Array.from(workspaces.values());
|
||||
session.workspaceRegistry.get = async (id: string) => workspaces.get(id) ?? null;
|
||||
session.workspaceRegistry.upsert = async (record: unknown) => {
|
||||
const parsed = record as typeof workspace;
|
||||
workspaces.set(parsed.workspaceId, parsed);
|
||||
session.workspaceRegistry.update = async (id, updater) => {
|
||||
const existing = workspaces.get(id);
|
||||
if (!existing) return null;
|
||||
const updated = updater(existing);
|
||||
workspaces.set(id, updated);
|
||||
return updated;
|
||||
};
|
||||
|
||||
session.workspaceUpdatesSubscription = {
|
||||
@@ -6611,6 +6634,72 @@ test("workspace.title.set.request stores the title and emits an updated descript
|
||||
});
|
||||
});
|
||||
|
||||
test("workspace.pin.set.request stores the pin timestamp and emits an updated descriptor", async () => {
|
||||
const emitted: SessionOutboundMessage[] = [];
|
||||
const session = asTestSession(
|
||||
createSessionForWorkspaceTests({ onMessage: (message) => emitted.push(message) }),
|
||||
);
|
||||
const project = createPersistedProjectRecord({
|
||||
projectId: "proj-1",
|
||||
rootPath: REPO_CWD,
|
||||
kind: "git",
|
||||
displayName: "acme/repo",
|
||||
createdAt: "2026-03-01T12:00:00.000Z",
|
||||
updatedAt: "2026-03-01T12:00:00.000Z",
|
||||
});
|
||||
const workspace = createPersistedWorkspaceRecord({
|
||||
workspaceId: "ws-1",
|
||||
projectId: project.projectId,
|
||||
cwd: REPO_CWD,
|
||||
kind: "local_checkout",
|
||||
displayName: "main",
|
||||
createdAt: "2026-03-01T12:00:00.000Z",
|
||||
updatedAt: "2026-03-01T12:00:00.000Z",
|
||||
});
|
||||
const workspaces = new Map([[workspace.workspaceId, workspace]]);
|
||||
session.projectRegistry.get = async (id: string) => (id === project.projectId ? project : null);
|
||||
session.projectRegistry.list = async () => [project];
|
||||
session.workspaceRegistry.list = async () => Array.from(workspaces.values());
|
||||
session.workspaceRegistry.update = async (id, updater) => {
|
||||
const existing = workspaces.get(id);
|
||||
if (!existing) return null;
|
||||
const updated = updater(existing);
|
||||
workspaces.set(id, updated);
|
||||
return updated;
|
||||
};
|
||||
session.workspaceUpdatesSubscription = {
|
||||
subscriptionId: "sub-workspaces",
|
||||
filter: {},
|
||||
isBootstrapping: false,
|
||||
lastEmittedByWorkspaceId: new Map(),
|
||||
pendingUpdatesByWorkspaceId: new Map(),
|
||||
};
|
||||
|
||||
await session.handleMessage({
|
||||
type: "workspace.pin.set.request",
|
||||
workspaceId: workspace.workspaceId,
|
||||
pinned: true,
|
||||
requestId: "req-pin-1",
|
||||
});
|
||||
|
||||
const response = findByType(emitted, "workspace.pin.set.response");
|
||||
expect(response?.payload).toMatchObject({
|
||||
requestId: "req-pin-1",
|
||||
workspaceId: "ws-1",
|
||||
accepted: true,
|
||||
error: null,
|
||||
});
|
||||
expect(response?.payload.pinnedAt).toEqual(expect.any(String));
|
||||
expect(workspaces.get("ws-1")?.pinnedAt).toBe(response?.payload.pinnedAt);
|
||||
expect(findByType(emitted, "workspace_update")?.payload).toMatchObject({
|
||||
kind: "upsert",
|
||||
workspace: {
|
||||
id: "ws-1",
|
||||
pinnedAt: response?.payload.pinnedAt,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test("workspace.title.set.request with whitespace-only title clears the title", async () => {
|
||||
const emitted: SessionOutboundMessage[] = [];
|
||||
const session = asTestSession(
|
||||
@@ -6631,9 +6720,12 @@ test("workspace.title.set.request with whitespace-only title clears the title",
|
||||
const workspaces = new Map([[workspace.workspaceId, workspace]]);
|
||||
session.workspaceRegistry.list = async () => Array.from(workspaces.values());
|
||||
session.workspaceRegistry.get = async (id: string) => workspaces.get(id) ?? null;
|
||||
session.workspaceRegistry.upsert = async (record: unknown) => {
|
||||
const parsed = record as typeof workspace;
|
||||
workspaces.set(parsed.workspaceId, parsed);
|
||||
session.workspaceRegistry.update = async (id, updater) => {
|
||||
const existing = workspaces.get(id);
|
||||
if (!existing) return null;
|
||||
const updated = updater(existing);
|
||||
workspaces.set(id, updated);
|
||||
return updated;
|
||||
};
|
||||
|
||||
await session.handleMessage({
|
||||
@@ -6659,7 +6751,7 @@ test("workspace.title.set.request returns accepted=false when workspace is not f
|
||||
const session = asTestSession(
|
||||
createSessionForWorkspaceTests({ onMessage: (message) => emitted.push(message) }),
|
||||
);
|
||||
session.workspaceRegistry.get = async () => null;
|
||||
session.workspaceRegistry.update = async () => null;
|
||||
|
||||
await session.handleMessage({
|
||||
type: "workspace.title.set.request",
|
||||
|
||||
@@ -230,6 +230,7 @@ function createNoopWorkspaceRegistry(): WorkspaceRegistry {
|
||||
existsOnDisk: async () => true,
|
||||
list: async () => [],
|
||||
get: async () => null,
|
||||
update: async () => null,
|
||||
upsert: async () => {},
|
||||
archive: async () => {},
|
||||
remove: async () => {},
|
||||
@@ -1261,6 +1262,8 @@ export class VoiceAssistantWebSocketServer {
|
||||
agentForkContext: true,
|
||||
// COMPAT(providerSubagents): added in v0.1.107, remove gate after 2027-01-12.
|
||||
providerSubagents: true,
|
||||
// COMPAT(workspacePinning): added in v0.1.107, remove gate after 2027-01-12.
|
||||
workspacePinning: true,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -523,6 +523,7 @@ describe("WorkspaceDirectory empty projects", () => {
|
||||
createdAt: NOW,
|
||||
updatedAt: NOW,
|
||||
archivedAt: null,
|
||||
pinnedAt: null,
|
||||
...input,
|
||||
} satisfies PersistedProjectRecord;
|
||||
}
|
||||
|
||||
@@ -211,4 +211,42 @@ describe("workspace registries", () => {
|
||||
expect(await workspaceRegistry.get("/tmp/repo")).toBeNull();
|
||||
expect(await workspaceRegistry.list()).toEqual([]);
|
||||
});
|
||||
|
||||
test("composes concurrent workspace field updates without losing either change", async () => {
|
||||
await workspaceRegistry.initialize();
|
||||
await workspaceRegistry.upsert(
|
||||
createPersistedWorkspaceRecord({
|
||||
workspaceId: "ws-1",
|
||||
projectId: "proj-1",
|
||||
cwd: "/tmp/repo",
|
||||
kind: "local_checkout",
|
||||
displayName: "main",
|
||||
createdAt: "2026-03-01T00:00:00.000Z",
|
||||
updatedAt: "2026-03-01T00:00:00.000Z",
|
||||
}),
|
||||
);
|
||||
|
||||
await Promise.all([
|
||||
workspaceRegistry.update("ws-1", (record) => ({
|
||||
...record,
|
||||
title: "Payments work",
|
||||
updatedAt: "2026-03-02T00:00:00.000Z",
|
||||
})),
|
||||
workspaceRegistry.update("ws-1", (record) => ({
|
||||
...record,
|
||||
pinnedAt: "2026-03-03T00:00:00.000Z",
|
||||
updatedAt: "2026-03-03T00:00:00.000Z",
|
||||
})),
|
||||
]);
|
||||
|
||||
const reloadedRegistry = new FileBackedWorkspaceRegistry(
|
||||
path.join(tmpDir, "projects", "workspaces.json"),
|
||||
logger,
|
||||
);
|
||||
await reloadedRegistry.initialize();
|
||||
expect(await reloadedRegistry.get("ws-1")).toMatchObject({
|
||||
title: "Payments work",
|
||||
pinnedAt: "2026-03-03T00:00:00.000Z",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -56,6 +56,11 @@ const PersistedWorkspaceRecordSchema = z.object({
|
||||
createdAt: z.string(),
|
||||
updatedAt: z.string(),
|
||||
archivedAt: z.string().nullable(),
|
||||
pinnedAt: z
|
||||
.string()
|
||||
.nullable()
|
||||
.optional()
|
||||
.transform((value) => value ?? null),
|
||||
});
|
||||
|
||||
export type PersistedProjectRecord = z.infer<typeof PersistedProjectRecordSchema>;
|
||||
@@ -76,6 +81,10 @@ export interface WorkspaceRegistry {
|
||||
existsOnDisk(): Promise<boolean>;
|
||||
list(): Promise<PersistedWorkspaceRecord[]>;
|
||||
get(workspaceId: string): Promise<PersistedWorkspaceRecord | null>;
|
||||
update(
|
||||
workspaceId: string,
|
||||
updater: (record: PersistedWorkspaceRecord) => PersistedWorkspaceRecord,
|
||||
): Promise<PersistedWorkspaceRecord | null>;
|
||||
upsert(record: PersistedWorkspaceRecord): Promise<void>;
|
||||
archive(workspaceId: string, archivedAt: string): Promise<void>;
|
||||
remove(workspaceId: string): Promise<void>;
|
||||
@@ -138,6 +147,18 @@ class FileBackedRegistry<TRecord extends RegistryRecord> {
|
||||
await this.enqueuePersist();
|
||||
}
|
||||
|
||||
async update(id: string, updater: (record: TRecord) => TRecord): Promise<TRecord | null> {
|
||||
await this.load();
|
||||
const existing = this.cache.get(id);
|
||||
if (!existing) {
|
||||
return null;
|
||||
}
|
||||
const next = this.schema.parse(updater(existing));
|
||||
this.cache.set(id, next);
|
||||
await this.enqueuePersist();
|
||||
return next;
|
||||
}
|
||||
|
||||
async archive(id: string, archivedAt: string): Promise<void> {
|
||||
await this.load();
|
||||
const existing = this.cache.get(id);
|
||||
@@ -257,6 +278,7 @@ export function createPersistedWorkspaceRecord(input: {
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
archivedAt?: string | null;
|
||||
pinnedAt?: string | null;
|
||||
}): PersistedWorkspaceRecord {
|
||||
return PersistedWorkspaceRecordSchema.parse({
|
||||
...input,
|
||||
@@ -264,6 +286,7 @@ export function createPersistedWorkspaceRecord(input: {
|
||||
branch: input.branch ?? null,
|
||||
baseBranch: input.baseBranch ?? null,
|
||||
archivedAt: input.archivedAt ?? null,
|
||||
pinnedAt: input.pinnedAt ?? null,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user