Skip to content

feat(sidebar): add session pinning#319

Merged
ewen-poch merged 3 commits into
aipoch:mainfrom
roxi3906:claude/gracious-maxwell-ad91bf
Jul 23, 2026
Merged

feat(sidebar): add session pinning#319
ewen-poch merged 3 commits into
aipoch:mainfrom
roxi3906:claude/gracious-maxwell-ad91bf

Conversation

@roxi3906

Copy link
Copy Markdown
Contributor

Summary

Implements the pin feature requested in #288.

Users can now pin any conversation to a dedicated Pinned section at the top of the sidebar. The section appears only when at least one conversation is pinned and disappears when all are unpinned. Multiple conversations can be pinned simultaneously, and pinned state survives app restarts.

Changes

Data layer

  • Added optional pinned?: boolean to PersistedChatSession in shared/session-persistence.ts. Only an explicit true is restored on load; legacy session files without the field default to unpinned with no migration required.

Store

  • Added togglePinned(sessionId) action to the session store. Flips the pinned flag and intentionally does not update updatedAt, so pinning never disturbs the last-active sort order within each section. Persisted automatically through the existing reference-diff saver — no IPC or persistence-bridge changes needed.

Sidebar UI

  • Sessions are partitioned into Pinned and Active sections; the Pinned section renders only when at least one conversation is pinned.
  • A Pin / Unpin menu item is added at the top of each session's context menu, with the icon switching between Pin and PinOff based on the current state.
  • onTogglePin callback wired through WorkspacePage.

Testing

  • Store: togglePinned toggles the flag, leaves updatedAt unchanged, and the flag round-trips through toPersistedSession.
  • Sidebar render: Pinned section appears above Active only when a pinned session exists; Pin/Unpin menu items call onTogglePin with the correct session.
  • All existing tests continue to pass (166 tests total).

No architecture changes

Sessions remain file-based; the database is not involved. The pinned field flows through the existing spread-based strip/hydrate path with no extra plumbing.


🤖 Generated with Claude Code

@github-actions github-actions Bot added the enhancement New feature or request label Jul 22, 2026
@roxi3906
roxi3906 force-pushed the claude/gracious-maxwell-ad91bf branch from 2f5fb5b to 63a92cc Compare July 23, 2026 06:12
roxi3906 and others added 2 commits July 23, 2026 14:14
- Add optional  field to PersistedChatSession; only an explicit
  true is restored on load so legacy session files stay unpinned by default
- Add togglePinned action to session store; flips the flag without updating
  updatedAt so pinning never disturbs the last-active sort order within
  each section; persisted automatically via the existing reference-diff saver
- Split sidebar session list into Pinned / Active sections; the Pinned
  section only renders when at least one conversation is pinned
- Add Pin / Unpin menu item at the top of each session's dropdown menu,
  with icon switching between Pin and PinOff based on current state
- Wire onTogglePin callback through WorkspacePage
- Add store tests (toggle, updatedAt unchanged, persistence roundtrip)
  and sidebar render tests (section ordering, menu item wiring)

Closes aipoch#288

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- Replace raw radix-ui DropdownMenu with shadcn @/components/ui/dropdown-menu
  to align with the rest of the workspace (ComposerAgentControlsMenu, etc.)
- Apply dark surface treatment: bg-text-000/text-bg-000 matching the app's
  tooltip token convention; border-transparent removes the light-mode border
- Hover state overridden to bg-white/10 (white overlay) so items remain
  readable against the dark background
- Delete item uses text-red-400 / data-[highlighted]:bg-red-500/15, a red
  accent suited for dark surfaces (replaces danger-900 light hover)
- Remove all DropdownMenu.Separator dividers per design direction
- Drop now-unused sessionMenuContentClassName, sessionMenuItemClassName,
  sessionMenuDangerItemClassName constants; keep sessionMenuIconClassName

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@roxi3906
roxi3906 force-pushed the claude/gracious-maxwell-ad91bf branch from 63a92cc to 69c3840 Compare July 23, 2026 06:15
Switch the session action dropdown from the dark tooltip surface back to
the shadcn default light popover (bg-popover/text-popover-foreground) to
match the app's other menus. Delete item uses the danger-000/danger-900
token pair suited for the light surface.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@roxi3906
roxi3906 marked this pull request as ready for review July 23, 2026 06:40
@ewen-poch
ewen-poch merged commit 57f465a into aipoch:main Jul 23, 2026
10 of 12 checks passed
@roxi3906
roxi3906 deleted the claude/gracious-maxwell-ad91bf branch July 23, 2026 06:43
ewen-poch added a commit that referenced this pull request Jul 23, 2026
…eminders

A conversation that fails to START (createSession) throws its setup-guidance
error from settings/service.ts:resolveActiveAgentBackend — model↔framework
incompatible, no active provider, unsupported Codex bridge model, missing Claude
executable. Unlike the resume path, this path does not route through the resume
softener, and the error crosses IPC wrapped ("Error invoking remote method …"),
so it was persisted verbatim and shown the "Report error" button — a wrong-config
setup problem masquerading as a reportable app bug.

- Export the four setup-guidance messages (and an active-model-incompatible
  builder + prefix) as shared constants in run-error-classification. service.ts
  builds its throws from these SAME constants, so the text can never drift from
  what the classifier recognizes (the fix the reviewer asked for).
- Recognize them as expected (exact-match set + the framework-name-independent
  incompatible prefix) so the report button is hidden.
- Unwrap the IPC wrapper on the createSession failure path (generalize the resume
  unwrapper to unwrapIpcErrorDetail) so the persisted text is clean and matches.

Also rebased onto current main (was 9 behind), so the session-pinning feature
(#319) the AI reviewer read as "removed" is correctly present.

Tests: classifier recognizes each config constant + the builder (drift guard);
the createSession failure path unwraps an IPC-wrapped incompat error and marks it
non-reportable. 500 tests, typecheck (node + web), lint clean.
ewen-poch added a commit that referenced this pull request Jul 23, 2026
…362)

* fix(workspace): report only unknown run failures, not expected ones

The "Report error" button opened a GitHub issue for every failed run,
including failures the app already recognizes and explains — a missing
workspace, a resume timeout, an incompatible model, an image-replay
mismatch, or a well-understood provider error (bad key, rate limit,
quota, overloaded, resource-not-found). Those are the user's or the
provider's to resolve, not app bugs, so surfacing "Report error" for
them invites noise in the issue tracker.

Add a shared, pure leaf module (src/shared/run-error-classification.ts,
mirroring media-overflow.ts) that classifies a persisted run-error
string as expected vs unknown, and gate the button on it: an expected
failure keeps its actionable message but drops the report affordance;
only an unknown/opaque failure (or one with no message) offers it.

The crafted messages now live as single-source constants that both the
producers (getResumeFailureMessage, the image-replay guard, and main's
describePromptError via a shared prefix) and the classifier reference,
so the two cannot drift. A drift-guard test feeds describePromptError's
real output through the classifier. Classifying the persisted string at
display time keeps recognition working across a reload with no schema
change.

* fix(workspace): anchor provider-error match to avoid swallowing app errors

Address review on #362:
- P1: PROVIDER_ERROR_PATTERN matched bare numbers and single generic words
  (401, quota, billing, overloaded, "rate limit"), so ordinary app errors
  (EACCES: permission denied, Failed to parse rate limit configuration,
  Record 503 could not be decoded) were classified as expected and lost their
  Report button. Anchor recognition to signals absent from app prose: exact
  underscore slugs, an HTTP status code with an explicit HTTP/status marker, a
  status code immediately followed by its canonical reason phrase, or an
  unmistakable multi-word reason phrase.
- P2: the code+reason branch now covers every 5xx the spec names (500/502/503/
  504), and the HTTP-marker branch covers any 3-digit code, so 500 Internal
  Server Error is expected. Add a 500 test.
- P3: move the shared isReportableRunFailure import into the shared import group
  in ConversationPanel.tsx.
- Add a false-positive regression test for the app-error prose above; switch the
  billing case to the billing_hard_limit slug.

* fix(workspace): tighten provider-error token boundaries and billing phrase

* fix(workspace): bound billing phrases, recognize verbatim provider auth/5xx

* fix(workspace): classify run-failure reportability structurally at the ACP layer

Determine whether a failed run offers Report error from where the failure
originated, not from its message text. The agent tags an upstream model/provider
failure structurally (APIError / errorKind provider-error / provider not-found);
runtime.ts marks the error event providerError, which flows through failRun into a
persisted session.errorReportable so only genuinely unexpected ACP-layer
exceptions stay reportable. The renderer text tier now recognizes ONLY the app's
own crafted reminder strings; it no longer guesses provider errors from text.

- shared/acp: add providerError to the error event (copied through pushEvent).
- prompt-error: export isProviderPromptError (APIError + provider-error kind + not-found).
- runtime: tag the prompt-failed event with providerError.
- session-store: failRun(opts.reportable); persist errorReportable; clear it wherever error clears; artifact errors are reportable.
- session-persistence: round-trip errorReportable.
- workspace-events + useWorkspaceAgentRuntime: pass the structural flag; the rejection path reads this turn's error event (baselined) instead of guessing.
- task-api: propagate the flag on the headless path.
- run-error-classification: drop the provider-error regex; keep only the exact crafted-message set.
- Tests across all layers: isProviderPromptError, providerError tagging, failRun reportability, persistence round-trip, and the rejection path.

* fix(workspace): recognize createSession config failures as expected reminders

A conversation that fails to START (createSession) throws its setup-guidance
error from settings/service.ts:resolveActiveAgentBackend — model↔framework
incompatible, no active provider, unsupported Codex bridge model, missing Claude
executable. Unlike the resume path, this path does not route through the resume
softener, and the error crosses IPC wrapped ("Error invoking remote method …"),
so it was persisted verbatim and shown the "Report error" button — a wrong-config
setup problem masquerading as a reportable app bug.

- Export the four setup-guidance messages (and an active-model-incompatible
  builder + prefix) as shared constants in run-error-classification. service.ts
  builds its throws from these SAME constants, so the text can never drift from
  what the classifier recognizes (the fix the reviewer asked for).
- Recognize them as expected (exact-match set + the framework-name-independent
  incompatible prefix) so the report button is hidden.
- Unwrap the IPC wrapper on the createSession failure path (generalize the resume
  unwrapper to unwrapIpcErrorDetail) so the persisted text is clean and matches.

Also rebased onto current main (was 9 behind), so the session-pinning feature
(#319) the AI reviewer read as "removed" is correctly present.

Tests: classifier recognizes each config constant + the builder (drift guard);
the createSession failure path unwraps an IPC-wrapped incompat error and marks it
non-reportable. 500 tests, typecheck (node + web), lint clean.

* fix(workspace): don't force-report a non-recovered context overflow

Both failure-writing paths derived reportability as `!providerError`, explicitly
passing `reportable: true` for every non-provider error. That value wins over
failRun's text tier (opts.reportable ?? isReportableRunFailure), so a
context-overflow that did NOT start recovery — a repeat overflow inside the
cooldown, nothing to replay, or a detached session — was persisted with the
"Report error" button even though the text tier recognizes overflow as an
expected, non-reportable failure. The overflow can carry providerError=false
(the agent CLI's client-side "Request too large (max 32MB)" ceiling trips before
dispatch, so it is neither an APIError nor tagged provider-error), which made the
`isMediaOverflowError` branch in isReportableRunFailure dead code on the event path.

Fix: only a provider-tagged failure forces reportable=false; everything else
leaves reportable undefined so the text tier decides. This keeps opaque ACP-layer
failures reportable while letting a non-recovered overflow stay non-reportable,
and makes the event path and the rejection path agree on the same rule.

Tests: assert errorReportable===false for the no-recovery overflow (fails against
the old forced-true expression — verified), errorReportable===true for an opaque
ACP-layer event, and a new provider-tagged event-path case that stays hidden.
295 acp/shared tests, typecheck (node + web), lint clean.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants