v1.4-fork-header: fork from message + delete message + header polish + housekeeping

- Fork: POST /api/chats/:id/fork creates a new chat in the same session,
  copies messages up to target (status=complete) with row-offset
  clock_timestamp() for stable ordering. Client emits open_chat_in_active_pane
  event; Workspace opens it in the active pane. No maybeAutoNameChat on forks.
- Delete: DELETE /api/chats/:id/messages/:message_id with 409 if the chat is
  currently streaming. Cascading-forward delete (created_at >= target).
  MessageBubble Trash button + confirm Dialog.
- Header: Projects -> Project -> Session breadcrumb, model badge pill,
  inline session rename, active file path via new useActivePane() hook.
  Server now publishes session_renamed on PATCH /api/sessions/:id;
  client-side dup emit removed from Session.tsx.
- Housekeeping: NOW() -> clock_timestamp() in schema.sql defaults, dead
  PaneTab.tsx and panes/PaneShell.tsx removed, session_panes backfill
  INSERT removed (CREATE TABLE retained), Tailnet trust comment near
  app.listen().

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-16 04:12:01 +00:00
parent eabef7671e
commit 59fe6f0522
16 changed files with 426 additions and 206 deletions

View File

@@ -57,6 +57,11 @@ export interface AttachChatFileEvent {
attachment: Omit<Attachment, 'id'>;
}
export interface OpenChatInActivePaneEvent {
type: 'open_chat_in_active_pane';
chat_id: string;
}
export interface SessionArchivedEvent {
type: 'session_archived';
session_id: string;
@@ -120,6 +125,7 @@ export type SessionEvent =
| SessionLoadedEvent
| OpenFileInBrowserEvent
| AttachChatFileEvent
| OpenChatInActivePaneEvent
| SessionArchivedEvent
| ChatCreatedEvent
| ChatUpdatedEvent

View File

@@ -0,0 +1,61 @@
import { useEffect, useState } from 'react';
import type { WorkspacePaneKind } from '@/api/types';
export interface ActivePaneSnapshot {
sessionId: string | null;
paneId: string | null;
kind: WorkspacePaneKind | null;
activeFile: string | null;
}
const EMPTY: ActivePaneSnapshot = {
sessionId: null,
paneId: null,
kind: null,
activeFile: null,
};
let current: ActivePaneSnapshot = EMPTY;
const subs = new Set<() => void>();
function notify(): void {
for (const sub of subs) {
try {
sub();
} catch {
// swallow — one bad listener shouldn't break others
}
}
}
function isSame(a: ActivePaneSnapshot, b: ActivePaneSnapshot): boolean {
return (
a.sessionId === b.sessionId &&
a.paneId === b.paneId &&
a.kind === b.kind &&
a.activeFile === b.activeFile
);
}
export function setActivePaneInfo(next: ActivePaneSnapshot): void {
if (isSame(current, next)) return;
current = next;
notify();
}
export function clearActivePane(): void {
setActivePaneInfo(EMPTY);
}
export function useActivePane(): ActivePaneSnapshot {
const [snap, setSnap] = useState<ActivePaneSnapshot>(current);
useEffect(() => {
const sub = () => setSnap(current);
subs.add(sub);
sub();
return () => {
subs.delete(sub);
};
}, []);
return snap;
}

View File

@@ -148,6 +148,9 @@ function applyEvent(prev: SidebarResponse, event: import('./sessionEvents').Sess
return prev;
case 'attach_chat_file':
return prev;
case 'open_chat_in_active_pane':
// Consumed by Workspace; sidebar has no business with pane state.
return prev;
case 'session_archived': {
let changed = false;
const projects = prev.projects.map((p) => {