Files
boocode/apps/web/src/hooks/sessionEvents.ts
indifferentketchup bee5597108 feat: git diff panel (Files/Git tab in the file browser)
Adds a Git tab to the right-side file panel that shows the project
repository's diff and lets the user stage, unstage, commit, and discard
whole files in-session. Two comparison modes (Uncommitted vs HEAD, and the
branch vs its base — upstream tracking branch else default branch), auto-
selected by repo state on first open and pinned after explicit choice;
per-file expand/collapse with lazy syntax-highlighted diffs, +/- stats, and
binary/large-file placeholders. All git read and write logic lives in
apps/server via a new git_diff service: argv-safe execFile only (never a
shell), per-file paths validated repo-relative through pathGuard with a
realpath symlink-escape check, server-derived commit identity (the request
carries no author fields), and the write endpoints are deliberately absent
from the assistant tool registry. Reads are bounded (30s deadline, 10MB);
an index lock or an in-progress merge/rebase/cherry-pick/bisect surfaces as
"repository busy" and disables writes. The panel stays current via a client
git_diff_refresh session event (no new wire contract) coalesced across tab
open, mutations, turn completion, and pending-change apply. Discard is an
irrecoverable hard-delete behind a plain confirm that distinguishes
reverting a tracked file from deleting an untracked one.
2026-06-03 03:18:41 +00:00

236 lines
5.7 KiB
TypeScript

// Tiny in-app event bus for session metadata changes that need to propagate
// across hooks (e.g. AI rename arriving via WS in the session view needs to
// also refresh the sidebar's session list).
import type {
Chat,
ErrorReason,
HtmlArtifactState,
MarkdownArtifactState,
Project,
Session,
} from '@/api/types';
import type { Attachment } from '@/lib/attachments';
export interface SessionRenamedEvent {
type: 'session_renamed';
session_id: string;
name: string;
}
export interface ProjectCreatedEvent {
type: 'project_created';
project: Project;
}
export interface ProjectDeletedEvent {
type: 'project_deleted';
project_id: string;
}
export interface SessionCreatedEvent {
type: 'session_created';
session: Session;
project_id: string;
}
export interface SessionDeletedEvent {
type: 'session_deleted';
session_id: string;
project_id: string;
}
export interface SessionUpdatedEvent {
type: 'session_updated';
session_id: string;
project_id: string;
name: string;
updated_at: string;
}
export interface SessionWorkspaceUpdatedEvent {
type: 'session_workspace_updated';
session_id: string;
// Legacy bare array OR the new envelope — useWorkspacePanes normalizes both
// via toWorkspaceState.
workspace_panes:
| import('@/api/types').WorkspacePane[]
| import('@/api/types').WorkspaceState;
}
export interface SessionLoadedEvent {
type: 'session_loaded';
session_id: string;
project_id: string;
}
export interface OpenFileInBrowserEvent {
type: 'open_file_in_browser';
path: string; // project-relative
}
export interface AttachChatFileEvent {
type: 'attach_chat_file';
attachment: Omit<Attachment, 'id'>;
}
export interface OpenChatInActivePaneEvent {
type: 'open_chat_in_active_pane';
chat_id: string;
}
// Open a whole chat in a fresh split pane (vs the active pane). Emitted by the
// ChatTabBar tab context menu ("Open in new pane") and by MessageBubble.fork()
// so a fork lands beside the original. useWorkspacePanes subscribes.
export interface OpenChatInNewPaneEvent {
type: 'open_chat_in_new_pane';
chat_id: string;
}
// v1.14.x-html-artifact-panes: ActionRow's "Open in pane" button emits one of
// these; useWorkspacePanes subscribes and inserts the corresponding artifact
// pane (or focuses an existing one keyed by message_id).
export interface OpenMarkdownArtifactPaneEvent {
type: 'open_markdown_artifact_pane';
state: MarkdownArtifactState;
}
export interface OpenHtmlArtifactPaneEvent {
type: 'open_html_artifact_pane';
state: HtmlArtifactState;
}
// Client-side event fired by the sidebar Settings button when a session is
// currently mounted. Session.tsx subscribes and calls
// panesHook.toggleSettingsPane() (open on first click, close on second).
// Sidebar handles the no-session case by navigating to /settings directly.
export interface OpenSettingsPaneEvent {
type: 'open_settings_pane';
}
export interface SessionArchivedEvent {
type: 'session_archived';
session_id: string;
project_id: string;
}
export interface ChatCreatedEvent {
type: 'chat_created';
chat: Chat;
session_id: string;
}
export interface ChatUpdatedEvent {
type: 'chat_updated';
chat_id: string;
session_id: string;
name: string | null;
updated_at: string;
}
export interface ChatArchivedEvent {
type: 'chat_archived';
chat_id: string;
session_id: string;
}
export interface ChatUnarchivedEvent {
type: 'chat_unarchived';
chat: Chat;
}
export interface ChatDeletedEvent {
type: 'chat_deleted';
chat_id: string;
session_id: string;
}
export interface ProjectArchivedEvent {
type: 'project_archived';
project_id: string;
}
export interface ProjectUnarchivedEvent {
type: 'project_unarchived';
project: Project;
}
export interface ProjectUpdatedEvent {
type: 'project_updated';
project_id: string;
name: string;
}
// v1.8 mobile-tabs: broadcast on user channel from inference.ts so any device
// subscribed sees a chat working/idle/error. Frontend stores per-chat; panes
// derive their dot from pane.activeChatId.
// v1.8.2: optional `reason` carries a machine-readable code when status is
// 'error'. UI prefers reason for inline error rendering.
export interface ChatStatusEvent {
type: 'chat_status';
chat_id: string;
status: 'streaming' | 'tool_running' | 'waiting_for_input' | 'idle' | 'error';
at: string;
reason?: ErrorReason;
}
export interface RefetchMessagesEvent {
type: 'refetch_messages';
}
// git-diff-panel Phase 1: emitted client-side to trigger a panel refresh.
// Not a WS frame — no @boocode/contracts change required.
export interface GitDiffRefreshEvent {
type: 'git_diff_refresh';
}
export type SessionEvent =
| SessionRenamedEvent
| ProjectCreatedEvent
| ProjectDeletedEvent
| SessionCreatedEvent
| SessionDeletedEvent
| SessionUpdatedEvent
| SessionWorkspaceUpdatedEvent
| SessionLoadedEvent
| OpenFileInBrowserEvent
| AttachChatFileEvent
| OpenChatInActivePaneEvent
| OpenChatInNewPaneEvent
| OpenMarkdownArtifactPaneEvent
| OpenHtmlArtifactPaneEvent
| OpenSettingsPaneEvent
| SessionArchivedEvent
| ChatCreatedEvent
| ChatUpdatedEvent
| ChatArchivedEvent
| ChatUnarchivedEvent
| ChatDeletedEvent
| ProjectArchivedEvent
| ProjectUnarchivedEvent
| ProjectUpdatedEvent
| ChatStatusEvent
| RefetchMessagesEvent
| GitDiffRefreshEvent;
type Listener = (event: SessionEvent) => void;
const listeners = new Set<Listener>();
export const sessionEvents = {
emit(event: SessionEvent) {
for (const listener of listeners) {
try {
listener(event);
} catch {
// swallow — one bad listener shouldn't break others
}
}
},
subscribe(listener: Listener): () => void {
listeners.add(listener);
return () => {
listeners.delete(listener);
};
},
};