diff --git a/chat-ui/src/App.tsx b/chat-ui/src/App.tsx index 7833f051..74cecfdd 100644 --- a/chat-ui/src/App.tsx +++ b/chat-ui/src/App.tsx @@ -31,14 +31,15 @@ const router = createBrowserRouter( { element: , children: [ - { index: true, element: }, + { path: "chat", element: }, + { path: "chat/s/:sessionId", element: }, + { path: "chat/new", element: }, { path: "s/:sessionId", element: }, { path: "new", element: }, { path: "*", element: }, ], }, ], - { basename: "/chat" }, ); export function App() { diff --git a/chat-ui/src/components/app-shell.tsx b/chat-ui/src/components/app-shell.tsx index c9f75b0a..cb548946 100644 --- a/chat-ui/src/components/app-shell.tsx +++ b/chat-ui/src/components/app-shell.tsx @@ -7,6 +7,7 @@ import { useKeyboard } from "@/hooks/use-keyboard"; import { useSessions } from "@/hooks/use-sessions"; import { useTheme } from "@/hooks/use-theme"; import { useIsMobile } from "@/hooks/use-mobile"; +import { CHAT_ROOT_PATH, chatSessionPath } from "@/lib/routes"; import { CommandPalette } from "./command-palette"; import { DeleteSessionDialog } from "./delete-session-dialog"; import { KeyboardHelpSheet } from "./keyboard-help-sheet"; @@ -15,7 +16,7 @@ import { SidebarPanel } from "./sidebar-panel"; export function AppShell({ children }: { children: React.ReactNode }) { const navigate = useNavigate(); const { sessionId } = useParams<{ sessionId: string }>(); - const { sessions, isLoading, createSession, deleteSession, updateSession } = + const { sessions, isLoading, refresh, createSession, deleteSession, updateSession } = useSessions(); const { toggleTheme } = useTheme(); const isMobile = useIsMobile(); @@ -32,6 +33,10 @@ export function AppShell({ children }: { children: React.ReactNode }) { } | null>(null); const [helpOpen, setHelpOpen] = useState(false); + useEffect(() => { + if (sessionId) refresh(); + }, [sessionId, refresh]); + // Update the browser tab title once we know the agent name. Picks up // cached name immediately on reload and refreshes when fresh data lands. useEffect(() => { @@ -70,12 +75,12 @@ export function AppShell({ children }: { children: React.ReactNode }) { const handleNewSession = useCallback(async () => { const id = await createSession(); - navigate(`/s/${id}`); + navigate(chatSessionPath(id)); }, [createSession, navigate]); const handleSessionClick = useCallback( (id: string) => { - navigate(`/s/${id}`); + navigate(chatSessionPath(id)); if (isMobile) setSidebarOpen(false); }, [navigate, isMobile], @@ -101,7 +106,7 @@ export function AppShell({ children }: { children: React.ReactNode }) { deleteSession(deleteTarget.id); setDeleteTarget(null); if (deleteTarget.id === sessionId) { - navigate("/"); + navigate(CHAT_ROOT_PATH); } }, [deleteTarget, deleteSession, sessionId, navigate]); @@ -147,7 +152,7 @@ export function AppShell({ children }: { children: React.ReactNode }) { {sidebarOpen && ( -
+
-
+
-
); diff --git a/chat-ui/src/components/run-activity-row.tsx b/chat-ui/src/components/run-activity-row.tsx index 63d355de..19000b19 100644 --- a/chat-ui/src/components/run-activity-row.tsx +++ b/chat-ui/src/components/run-activity-row.tsx @@ -182,9 +182,9 @@ export function RunActivityRow({ return (
-
+
-
+
@@ -212,7 +212,7 @@ export function RunActivityRow({ {facts.map((fact) => ( {fact} @@ -253,7 +253,7 @@ export function RunActivityRow({
{toolCalls.length > 0 && ( -
+
{toolCalls.map((tool) => ( ))} diff --git a/chat-ui/src/components/sidebar-panel.tsx b/chat-ui/src/components/sidebar-panel.tsx index afaee2a1..c9ce959c 100644 --- a/chat-ui/src/components/sidebar-panel.tsx +++ b/chat-ui/src/components/sidebar-panel.tsx @@ -23,7 +23,7 @@ export function SidebarPanel({ }) { return (
-
+
Conversations @@ -32,7 +32,7 @@ export function SidebarPanel({ size="icon" onClick={onNewSession} aria-label="New conversation" - className="h-7 w-7" + className="h-8 w-8 rounded-md" > diff --git a/chat-ui/src/components/tool-call-card.tsx b/chat-ui/src/components/tool-call-card.tsx index daab030a..002ca033 100644 --- a/chat-ui/src/components/tool-call-card.tsx +++ b/chat-ui/src/components/tool-call-card.tsx @@ -129,27 +129,67 @@ type StateStyle = { border: string; icon: typeof Terminal; iconClass: string; - showSpinner?: boolean; + badgeClass: string; }; function getStateStyle(state: ToolCallState["state"]): StateStyle { switch (state) { case "pending": - return { border: "border-muted", icon: Terminal, iconClass: "animate-pulse text-muted-foreground" }; + return { + border: "border-border/60", + icon: Terminal, + iconClass: "animate-pulse text-muted-foreground", + badgeClass: "border-border bg-muted/55 text-muted-foreground", + }; case "input_streaming": - return { border: "border-primary/50 animate-pulse", icon: Terminal, iconClass: "text-primary" }; + return { + border: "border-primary/35 animate-pulse", + icon: Terminal, + iconClass: "text-primary", + badgeClass: "border-primary/25 bg-primary/10 text-primary", + }; case "input_complete": - return { border: "border-border", icon: Terminal, iconClass: "text-foreground" }; + return { + border: "border-border/70", + icon: Terminal, + iconClass: "text-foreground", + badgeClass: "border-border bg-muted/45 text-muted-foreground", + }; case "running": - return { border: "border-primary/40", icon: Loader2, iconClass: "text-primary animate-spin", showSpinner: true }; + return { + border: "border-primary/35", + icon: Loader2, + iconClass: "text-primary animate-spin", + badgeClass: "border-primary/25 bg-primary/10 text-primary", + }; case "result": - return { border: "border-border", icon: Check, iconClass: "text-success" }; + return { + border: "border-border/70", + icon: Check, + iconClass: "text-success", + badgeClass: "border-success/20 bg-success/10 text-success", + }; case "error": - return { border: "border-error", icon: XCircle, iconClass: "text-error" }; + return { + border: "border-error/70", + icon: XCircle, + iconClass: "text-error", + badgeClass: "border-error/25 bg-error/10 text-error", + }; case "aborted": - return { border: "border-muted", icon: AlertCircle, iconClass: "text-muted-foreground line-through" }; + return { + border: "border-border/60", + icon: AlertCircle, + iconClass: "text-muted-foreground line-through", + badgeClass: "border-border bg-muted/45 text-muted-foreground", + }; case "blocked": - return { border: "border-warning", icon: Shield, iconClass: "text-warning" }; + return { + border: "border-warning/70", + icon: Shield, + iconClass: "text-warning", + badgeClass: "border-warning/25 bg-warning/10 text-warning", + }; } } @@ -170,25 +210,35 @@ export function ToolCallCard({ tool }: { tool: ToolCallState }) { const isOpen = disclosure.isOpen; const hasBody = Boolean(output || tool.error || tool.blockReason || inputDetails || tool.fullRef); + const detailLabel = isOpen ? "Hide details" : "View details"; return ( -
+
{isOpen && hasBody && ( -
+
{inputDetails && (
{inputDetails.label}
-
+							
 								{inputDetails.value}
 							
@@ -230,7 +281,7 @@ export function ToolCallCard({ tool }: { tool: ToolCallState }) {
Full output path
-
+
{redactSensitiveText(tool.fullRef)}
@@ -238,7 +289,7 @@ export function ToolCallCard({ tool }: { tool: ToolCallState }) { {output && (
Output
-
+							
 								{output}
 							
diff --git a/chat-ui/src/hooks/use-chat.ts b/chat-ui/src/hooks/use-chat.ts index 34a2a320..c948789b 100644 --- a/chat-ui/src/hooks/use-chat.ts +++ b/chat-ui/src/hooks/use-chat.ts @@ -9,7 +9,7 @@ import type { ThinkingBlockState, ToolCallState, } from "@/lib/chat-types"; -import { type SessionDetail, abortSession, getSession, resumeSession } from "@/lib/client"; +import { type SessionDetail, abortSession, getSession, notifySessionsChanged, resumeSession } from "@/lib/client"; import { useCallback, useRef, useSyncExternalStore } from "react"; export function useChat(sessionId: string | null): { @@ -71,6 +71,10 @@ export function useChat(sessionId: string | null): { dispatchFrame(store, currentEvent, currentData, { source: replaying ? "replay" : "live" }); if (currentEvent === "session.caught_up") { replaying = false; + } else if (currentEvent === "session.done") { + notifySessionsChanged("run-completed"); + } else if (currentEvent === "session.title_updated") { + notifySessionsChanged("updated"); } currentEvent = ""; currentData = ""; diff --git a/chat-ui/src/hooks/use-notifications.ts b/chat-ui/src/hooks/use-notifications.ts index 950b4eaf..d5267bb6 100644 --- a/chat-ui/src/hooks/use-notifications.ts +++ b/chat-ui/src/hooks/use-notifications.ts @@ -19,7 +19,7 @@ function urlBase64ToUint8Array(base64String: string): Uint8Array { return outputArray; } -export function useNotifications(): { +export function useNotifications({ enabled }: { enabled: boolean }): { permission: NotificationState["permission"]; subscribed: boolean; subscribe: () => Promise; @@ -37,6 +37,8 @@ export function useNotifications(): { // Register Service Worker on mount useEffect(() => { + if (!enabled) return; + if (!("serviceWorker" in navigator) || !("PushManager" in window)) { setState((s) => ({ ...s, permission: "unsupported" })); return; @@ -70,7 +72,7 @@ export function useNotifications(): { } }) .catch(() => {}); - }, []); + }, [enabled]); const subscribe = useCallback(async (): Promise => { if (!state.swRegistration || !vapidKeyRef.current) return false; diff --git a/chat-ui/src/hooks/use-sessions.ts b/chat-ui/src/hooks/use-sessions.ts index 78e5db12..51ffb243 100644 --- a/chat-ui/src/hooks/use-sessions.ts +++ b/chat-ui/src/hooks/use-sessions.ts @@ -3,6 +3,7 @@ import { createSession as apiCreateSession, deleteSession as apiDeleteSession, listSessions, + SESSIONS_CHANGED_EVENT, updateSession as apiUpdateSession, type SessionSummary, } from "@/lib/client"; @@ -43,6 +44,11 @@ export function useSessions(): UseSessionsReturn { refresh(); }, [refresh]); + useEffect(() => { + window.addEventListener(SESSIONS_CHANGED_EVENT, refresh); + return () => window.removeEventListener(SESSIONS_CHANGED_EVENT, refresh); + }, [refresh]); + const createSession = useCallback( async (title?: string): Promise => { const result = await apiCreateSession(title); diff --git a/chat-ui/src/lib/__tests__/routes.test.ts b/chat-ui/src/lib/__tests__/routes.test.ts new file mode 100644 index 00000000..ff378c92 --- /dev/null +++ b/chat-ui/src/lib/__tests__/routes.test.ts @@ -0,0 +1,18 @@ +import { describe, expect, it } from "vitest"; +import { CHAT_ROOT_PATH, chatSessionPath, legacyChatSessionPath } from "../routes"; + +describe("chat routes", () => { + it("uses the deployed chat root as the canonical browser path", () => { + expect(CHAT_ROOT_PATH).toBe("/chat"); + expect(chatSessionPath("session-123")).toBe("/chat/s/session-123"); + }); + + it("keeps the legacy session path available for old links", () => { + expect(legacyChatSessionPath("session-123")).toBe("/s/session-123"); + }); + + it("encodes session ids before placing them in the URL", () => { + expect(chatSessionPath("session/with space")).toBe("/chat/s/session%2Fwith%20space"); + expect(legacyChatSessionPath("session/with space")).toBe("/s/session%2Fwith%20space"); + }); +}); diff --git a/chat-ui/src/lib/__tests__/tool-disclosure.test.ts b/chat-ui/src/lib/__tests__/tool-disclosure.test.ts index f766ba1d..1a635a72 100644 --- a/chat-ui/src/lib/__tests__/tool-disclosure.test.ts +++ b/chat-ui/src/lib/__tests__/tool-disclosure.test.ts @@ -10,6 +10,16 @@ describe("tool disclosure policy", () => { }); }); + it("starts routine tool states collapsed", () => { + for (const state of ["pending", "input_streaming", "input_complete", "running"] as const) { + expect(initialToolDisclosureState(state)).toMatchObject({ + isOpen: false, + lastToolState: state, + userInteracted: false, + }); + } + }); + it("starts blocked and errored tools open", () => { expect(initialToolDisclosureState("blocked").isOpen).toBe(true); expect(initialToolDisclosureState("error").isOpen).toBe(true); diff --git a/chat-ui/src/lib/client.ts b/chat-ui/src/lib/client.ts index fa019026..cf0b5f65 100644 --- a/chat-ui/src/lib/client.ts +++ b/chat-ui/src/lib/client.ts @@ -9,6 +9,7 @@ export type BootstrapData = { avatar_url: string | null; memory_count: number; slack_status: string; + push_notifications_enabled: boolean; scheduled_jobs_count: number; recent_sessions_count: number; suggestions: string[]; @@ -74,6 +75,15 @@ export type ListSessionsResult = { next_cursor: string | null; }; +export const SESSIONS_CHANGED_EVENT = "phantom:sessions-changed"; + +export type SessionsChangedReason = "created" | "updated" | "deleted" | "run-completed"; + +export function notifySessionsChanged(reason: SessionsChangedReason): void { + if (typeof window === "undefined") return; + window.dispatchEvent(new CustomEvent(SESSIONS_CHANGED_EVENT, { detail: { reason } })); +} + async function chatFetch(path: string, options?: RequestInit): Promise { const res = await fetch(path, { credentials: "include", @@ -101,25 +111,31 @@ export function getSession(id: string): Promise { return chatFetch(`/chat/sessions/${id}`); } -export function createSession(title?: string): Promise<{ id: string; created_at: string }> { - return chatFetch("/chat/sessions", { +export async function createSession(title?: string): Promise<{ id: string; created_at: string }> { + const result = await chatFetch<{ id: string; created_at: string }>("/chat/sessions", { method: "POST", body: JSON.stringify(title ? { title } : {}), }); + notifySessionsChanged("created"); + return result; } -export function updateSession( +export async function updateSession( id: string, fields: { title?: string; pinned?: boolean; status?: string }, ): Promise<{ ok: boolean }> { - return chatFetch(`/chat/sessions/${id}`, { + const result = await chatFetch<{ ok: boolean }>(`/chat/sessions/${id}`, { method: "PATCH", body: JSON.stringify(fields), }); + notifySessionsChanged("updated"); + return result; } -export function deleteSession(id: string): Promise<{ ok: boolean; undo_until: string }> { - return chatFetch(`/chat/sessions/${id}`, { method: "DELETE" }); +export async function deleteSession(id: string): Promise<{ ok: boolean; undo_until: string }> { + const result = await chatFetch<{ ok: boolean; undo_until: string }>(`/chat/sessions/${id}`, { method: "DELETE" }); + notifySessionsChanged("deleted"); + return result; } export function abortSession(id: string): Promise { diff --git a/chat-ui/src/lib/routes.ts b/chat-ui/src/lib/routes.ts new file mode 100644 index 00000000..0510494c --- /dev/null +++ b/chat-ui/src/lib/routes.ts @@ -0,0 +1,9 @@ +export const CHAT_ROOT_PATH = "/chat"; + +export function chatSessionPath(sessionId: string): string { + return `${CHAT_ROOT_PATH}/s/${encodeURIComponent(sessionId)}`; +} + +export function legacyChatSessionPath(sessionId: string): string { + return `/s/${encodeURIComponent(sessionId)}`; +} diff --git a/chat-ui/src/routes/chat-route.tsx b/chat-ui/src/routes/chat-route.tsx index 0facf499..3d041907 100644 --- a/chat-ui/src/routes/chat-route.tsx +++ b/chat-ui/src/routes/chat-route.tsx @@ -3,6 +3,7 @@ import { useNavigate } from "react-router-dom"; import { EmptyState } from "@/components/empty-state"; import { ChatInput } from "@/components/chat-input"; import { createSession } from "@/lib/client"; +import { CHAT_ROOT_PATH, chatSessionPath } from "@/lib/routes"; const PREFILL_MAX = 2000; @@ -35,7 +36,7 @@ export function ChatRoute() { const prefill = readPrefill(); if (prefill === null) return; setInitialText(prefill); - window.history.replaceState({}, "", "/chat"); + window.history.replaceState({}, "", CHAT_ROOT_PATH); }, []); const handleCreateAndNavigate = useCallback( @@ -44,7 +45,7 @@ export function ChatRoute() { creatingRef.current = true; try { const result = await createSession(); - navigate(`/s/${result.id}`, { state: { initialMessage: text } }); + navigate(chatSessionPath(result.id), { state: { initialMessage: text } }); } finally { creatingRef.current = false; } diff --git a/chat-ui/src/routes/new-chat-route.tsx b/chat-ui/src/routes/new-chat-route.tsx index 206e64ec..49eb7e5e 100644 --- a/chat-ui/src/routes/new-chat-route.tsx +++ b/chat-ui/src/routes/new-chat-route.tsx @@ -1,6 +1,7 @@ import { useEffect, useRef } from "react"; import { useNavigate } from "react-router-dom"; import { createSession } from "@/lib/client"; +import { CHAT_ROOT_PATH, chatSessionPath } from "@/lib/routes"; export function NewChatRoute() { const navigate = useNavigate(); @@ -11,10 +12,10 @@ export function NewChatRoute() { didCreate.current = true; createSession() .then((result) => { - navigate(`/s/${result.id}`, { replace: true }); + navigate(chatSessionPath(result.id), { replace: true }); }) .catch(() => { - navigate("/", { replace: true }); + navigate(CHAT_ROOT_PATH, { replace: true }); }); }, [navigate]); diff --git a/chat-ui/tsconfig.app.tsbuildinfo b/chat-ui/tsconfig.app.tsbuildinfo index ff46a891..fac986b3 100644 --- a/chat-ui/tsconfig.app.tsbuildinfo +++ b/chat-ui/tsconfig.app.tsbuildinfo @@ -1 +1 @@ -{"root":["./src/app.tsx","./src/main.tsx","./src/vite-env.d.ts","./src/components/app-shell.tsx","./src/components/assistant-message.tsx","./src/components/attachment-strip.tsx","./src/components/attachment-tile.tsx","./src/components/chat-input-toolbar.tsx","./src/components/chat-input.tsx","./src/components/code-block.tsx","./src/components/command-palette.tsx","./src/components/delete-session-dialog.tsx","./src/components/drop-overlay.tsx","./src/components/empty-state.tsx","./src/components/ios-install-banner.tsx","./src/components/keyboard-help-sheet.tsx","./src/components/markdown.tsx","./src/components/message-actions.tsx","./src/components/message-list.tsx","./src/components/message.tsx","./src/components/notification-banner.tsx","./src/components/sidebar-footer.tsx","./src/components/sidebar-panel.tsx","./src/components/sidebar-session-item.tsx","./src/components/sidebar-session-list.tsx","./src/components/theme-toggle.tsx","./src/components/thinking-block.tsx","./src/components/tool-call-card.tsx","./src/components/user-message.tsx","./src/hooks/use-attachments.ts","./src/hooks/use-auto-scroll.ts","./src/hooks/use-bootstrap.ts","./src/hooks/use-chat.ts","./src/hooks/use-drag-drop.ts","./src/hooks/use-focus-heartbeat.ts","./src/hooks/use-keyboard.ts","./src/hooks/use-mobile.ts","./src/hooks/use-notifications.ts","./src/hooks/use-paste.ts","./src/hooks/use-sessions.ts","./src/hooks/use-theme.ts","./src/lib/chat-dispatch-tools.ts","./src/lib/chat-store.ts","./src/lib/chat-types.ts","./src/lib/client.ts","./src/lib/keymap.ts","./src/lib/utils.ts","./src/lib/__tests__/chat-store.test.ts","./src/routes/chat-route.tsx","./src/routes/new-chat-route.tsx","./src/routes/not-found-route.tsx","./src/routes/session-route.tsx","./src/ui/alert-dialog.tsx","./src/ui/avatar.tsx","./src/ui/badge.tsx","./src/ui/button.tsx","./src/ui/card.tsx","./src/ui/collapsible.tsx","./src/ui/command.tsx","./src/ui/dialog.tsx","./src/ui/dropdown-menu.tsx","./src/ui/input.tsx","./src/ui/label.tsx","./src/ui/popover.tsx","./src/ui/scroll-area.tsx","./src/ui/separator.tsx","./src/ui/sheet.tsx","./src/ui/sidebar.tsx","./src/ui/skeleton.tsx","./src/ui/sonner.tsx","./src/ui/tabs.tsx","./src/ui/textarea.tsx","./src/ui/tooltip.tsx"],"version":"5.9.3"} \ No newline at end of file +{"root":["./src/app.tsx","./src/main.tsx","./src/vite-env.d.ts","./src/components/app-shell.tsx","./src/components/artifact-tray.tsx","./src/components/assistant-message.tsx","./src/components/attachment-strip.tsx","./src/components/attachment-tile.tsx","./src/components/chat-input-toolbar.tsx","./src/components/chat-input.tsx","./src/components/code-block.tsx","./src/components/command-palette.tsx","./src/components/delete-session-dialog.tsx","./src/components/drop-overlay.tsx","./src/components/empty-state.tsx","./src/components/ios-install-banner.tsx","./src/components/keyboard-help-sheet.tsx","./src/components/markdown.tsx","./src/components/message-actions.tsx","./src/components/message-list.tsx","./src/components/message.tsx","./src/components/notification-banner.tsx","./src/components/run-activity-row.tsx","./src/components/sidebar-footer.tsx","./src/components/sidebar-panel.tsx","./src/components/sidebar-session-item.tsx","./src/components/sidebar-session-list.tsx","./src/components/theme-toggle.tsx","./src/components/thinking-block.tsx","./src/components/tool-call-card.tsx","./src/components/user-message.tsx","./src/hooks/use-attachments.ts","./src/hooks/use-auto-scroll.ts","./src/hooks/use-bootstrap.ts","./src/hooks/use-chat.ts","./src/hooks/use-drag-drop.ts","./src/hooks/use-focus-heartbeat.ts","./src/hooks/use-keyboard.ts","./src/hooks/use-mobile.ts","./src/hooks/use-notifications.ts","./src/hooks/use-paste.ts","./src/hooks/use-sessions.ts","./src/hooks/use-theme.ts","./src/hooks/__tests__/use-chat.test.ts","./src/lib/chat-activity.ts","./src/lib/chat-artifacts.ts","./src/lib/chat-dispatch-tools.ts","./src/lib/chat-message-content.ts","./src/lib/chat-store.ts","./src/lib/chat-types.ts","./src/lib/client.ts","./src/lib/keymap.ts","./src/lib/routes.ts","./src/lib/timeline-view.ts","./src/lib/tool-disclosure.ts","./src/lib/utils.ts","./src/lib/__tests__/chat-activity.test.ts","./src/lib/__tests__/chat-artifacts.test.ts","./src/lib/__tests__/chat-store.test.ts","./src/lib/__tests__/routes.test.ts","./src/lib/__tests__/tool-disclosure.test.ts","./src/routes/chat-route.tsx","./src/routes/new-chat-route.tsx","./src/routes/not-found-route.tsx","./src/routes/session-route.tsx","./src/ui/alert-dialog.tsx","./src/ui/avatar.tsx","./src/ui/badge.tsx","./src/ui/button.tsx","./src/ui/card.tsx","./src/ui/collapsible.tsx","./src/ui/command.tsx","./src/ui/dialog.tsx","./src/ui/dropdown-menu.tsx","./src/ui/input.tsx","./src/ui/label.tsx","./src/ui/popover.tsx","./src/ui/scroll-area.tsx","./src/ui/separator.tsx","./src/ui/sheet.tsx","./src/ui/sidebar.tsx","./src/ui/skeleton.tsx","./src/ui/sonner.tsx","./src/ui/tabs.tsx","./src/ui/textarea.tsx","./src/ui/tooltip.tsx"],"version":"5.9.3"} \ No newline at end of file diff --git a/src/chat/__tests__/event-log.test.ts b/src/chat/__tests__/event-log.test.ts index 9d7c88b8..36e34bdd 100644 --- a/src/chat/__tests__/event-log.test.ts +++ b/src/chat/__tests__/event-log.test.ts @@ -68,16 +68,17 @@ describe("ChatEventLog", () => { expect(log.getLatestTerminalSeq("sess-1")).toBe(0); }); - test("getStreamState treats benign post-terminal suggestions as complete", () => { + test("getStreamState treats benign post-terminal metadata as complete", () => { log.append("sess-1", null, 1, "message.assistant_start", {}); log.append("sess-1", null, 2, "session.done", {}); log.append("sess-1", null, 3, "session.suggestion", {}); + log.append("sess-1", null, 4, "session.title_updated", {}); - expect(log.getMaxSeq("sess-1")).toBe(3); + expect(log.getMaxSeq("sess-1")).toBe(4); expect(log.getLatestTerminalSeq("sess-1")).toBe(2); expect(log.getLatestRecoveryRelevantSeq("sess-1")).toBe(2); expect(log.getStreamState("sess-1", false)).toEqual({ - maxSeq: 3, + maxSeq: 4, latestTerminalSeq: 2, writerActive: false, hasIncompleteTail: false, diff --git a/src/chat/__tests__/http.test.ts b/src/chat/__tests__/http.test.ts index 50ff4edf..737a0985 100644 --- a/src/chat/__tests__/http.test.ts +++ b/src/chat/__tests__/http.test.ts @@ -64,6 +64,7 @@ describe("Chat HTTP handlers", () => { expect(res?.status).toBe(200); const body = await res?.json(); expect(body.agent_name).toBe("TestAgent"); + expect(body.push_notifications_enabled).toBe(false); }); test("POST /chat/sessions creates a session", async () => { diff --git a/src/chat/__tests__/serve.test.ts b/src/chat/__tests__/serve.test.ts new file mode 100644 index 00000000..6a5e6dfc --- /dev/null +++ b/src/chat/__tests__/serve.test.ts @@ -0,0 +1,49 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { setPublicDir } from "../../ui/serve.ts"; +import { handleChatStaticRequest } from "../serve.ts"; + +let publicDir: string; + +beforeEach(async () => { + publicDir = await mkdtemp(join(tmpdir(), "phantom-chat-static-")); + await mkdir(join(publicDir, "chat"), { recursive: true }); + await writeFile(join(publicDir, "chat", "index.html"), '
chat app
'); + await writeFile(join(publicDir, "chat", "asset.txt"), "asset bytes"); + setPublicDir(publicDir); +}); + +afterEach(async () => { + await rm(publicDir, { recursive: true, force: true }); +}); + +function req(path: string): Request { + return new Request(`http://localhost:3100${path}`, { headers: { Accept: "text/html" } }); +} + +describe("handleChatStaticRequest", () => { + test("serves concrete /chat assets from public/chat", async () => { + const res = await handleChatStaticRequest(req("/chat/asset.txt")); + expect(res?.status).toBe(200); + expect(await res?.text()).toBe("asset bytes"); + }); + + test("falls back to the chat SPA for /chat session routes", async () => { + const res = await handleChatStaticRequest(req("/chat/s/session-123")); + expect(res?.status).toBe(200); + expect(await res?.text()).toContain("chat app"); + }); + + test("falls back to the chat SPA for legacy /s session routes", async () => { + const res = await handleChatStaticRequest(req("/s/session-123")); + expect(res?.status).toBe(200); + expect(await res?.text()).toContain("chat app"); + }); + + test("ignores non-chat paths", async () => { + const res = await handleChatStaticRequest(req("/ui/")); + expect(res).toBeNull(); + }); +}); diff --git a/src/chat/__tests__/writer.test.ts b/src/chat/__tests__/writer.test.ts index 1aa07894..58ffc507 100644 --- a/src/chat/__tests__/writer.test.ts +++ b/src/chat/__tests__/writer.test.ts @@ -19,6 +19,10 @@ let eventLog: ChatEventLog; let timelineStore: ChatRunTimelineStore; let streamBus: StreamBus; +function nextTick(): Promise { + return new Promise((resolve) => setTimeout(resolve, 0)); +} + beforeEach(() => { db = new Database(":memory:"); for (const sql of MIGRATIONS) { @@ -107,6 +111,15 @@ describe("ChatSessionWriter", () => { expect(eventTypes).toContain("user.message"); expect(eventTypes).toContain("session.created"); expect(eventTypes).toContain("session.done"); + + await nextTick(); + const titleFrame = frames.find((f) => f.event === "session.title_updated"); + expect(titleFrame).toMatchObject({ + event: "session.title_updated", + session_id: session.id, + title: "Test Chat", + }); + expect(sessionStore.get(session.id)?.title).toBe("Test Chat"); }); test("commits attachments to the user message and emits metadata", async () => { diff --git a/src/chat/auto-rename.ts b/src/chat/auto-rename.ts index c237d165..0e005d44 100644 --- a/src/chat/auto-rename.ts +++ b/src/chat/auto-rename.ts @@ -12,7 +12,7 @@ export async function autoRenameSession( sessionId: string, userMessage: string, assistantMessage: string, -): Promise { +): Promise { try { const result = await runtime.judgeQuery({ systemPrompt: 'Generate a concise 3-5 word title for this conversation. Return JSON: {"title": "..."}.', @@ -21,12 +21,17 @@ export async function autoRenameSession( omitPreset: true, }); - if (result.data.title) { - sessionStore.setAutoTitle(sessionId, result.data.title); - console.log(`[chat] Auto-renamed session ${sessionId}: "${result.data.title}"`); + const title = result.data.title.trim(); + if (title) { + const changed = sessionStore.setAutoTitle(sessionId, title); + if (changed) { + console.log(`[chat] Auto-renamed session ${sessionId}: "${title}"`); + return title; + } } } catch (err: unknown) { const msg = err instanceof Error ? err.message : String(err); console.warn(`[chat] Auto-rename failed for session ${sessionId}: ${msg}`); } + return null; } diff --git a/src/chat/event-log.ts b/src/chat/event-log.ts index f8b89242..478ecb4c 100644 --- a/src/chat/event-log.ts +++ b/src/chat/event-log.ts @@ -76,10 +76,11 @@ export class ChatEventLog { } getLatestRecoveryRelevantSeq(sessionId: string): number { + const placeholders = CHAT_POST_TERMINAL_NON_RECOVERY_EVENT_TYPES.map(() => "?").join(", "); const row = this.db .query( `SELECT MAX(seq) as max_seq FROM chat_stream_events - WHERE session_id = ? AND event_type NOT IN (?)`, + WHERE session_id = ? AND event_type NOT IN (${placeholders})`, ) .get(sessionId, ...CHAT_POST_TERMINAL_NON_RECOVERY_EVENT_TYPES) as { max_seq: number | null } | null; return row?.max_seq ?? 0; diff --git a/src/chat/http.ts b/src/chat/http.ts index 17936d2b..d6228fc8 100644 --- a/src/chat/http.ts +++ b/src/chat/http.ts @@ -98,7 +98,11 @@ function isApiPath(path: string): boolean { async function routeApi(req: Request, url: URL, path: string, deps: ChatHandlerDeps): Promise { if (path === "/chat/bootstrap" && req.method === "GET") { const base = deps.getBootstrapData?.() ?? {}; - return Response.json({ ...base, avatar_url: avatarUrlIfPresent() }); + return Response.json({ + ...base, + avatar_url: avatarUrlIfPresent(), + push_notifications_enabled: Boolean(deps.db && deps.vapidKeys), + }); } if (path === "/chat/sessions" && req.method === "POST") { diff --git a/src/chat/serve.ts b/src/chat/serve.ts index 743c40a5..9054fc69 100644 --- a/src/chat/serve.ts +++ b/src/chat/serve.ts @@ -2,8 +2,8 @@ import { relative, resolve } from "node:path"; import { getPublicDir } from "../ui/serve.ts"; // Serve static files from public/chat/ with SPA fallback. -// All paths under /chat/ that don't match an API route get served here. -// If the file doesn't exist, return index.html (SPA routing). +// Chat historically navigates session routes at /s/:id while the app assets +// live under /chat/. Both URL shapes should refresh into the same SPA. function getChatDir(): string { return resolve(getPublicDir(), "chat"); @@ -25,9 +25,13 @@ function isPathSafe(urlPath: string, chatDir: string): string | null { } } +function isChatStaticPath(pathname: string): boolean { + return pathname.startsWith("/chat") || pathname === "/s" || pathname.startsWith("/s/") || pathname === "/new"; +} + export async function handleChatStaticRequest(req: Request): Promise { const url = new URL(req.url); - if (!url.pathname.startsWith("/chat")) return null; + if (!isChatStaticPath(url.pathname)) return null; const chatDir = getChatDir(); const filePath = isPathSafe(url.pathname, chatDir); diff --git a/src/chat/session-store.ts b/src/chat/session-store.ts index a3ee0d6f..128cde88 100644 --- a/src/chat/session-store.ts +++ b/src/chat/session-store.ts @@ -214,11 +214,12 @@ export class ChatSessionStore { return ids.length; } - setAutoTitle(id: string, title: string): void { - this.db.run( + setAutoTitle(id: string, title: string): boolean { + const result = this.db.run( `UPDATE chat_sessions SET title = ?, updated_at = datetime('now') WHERE id = ? AND title IS NULL AND title_is_manual = 0`, [title, id], ); + return result.changes > 0; } } diff --git a/src/chat/sse.ts b/src/chat/sse.ts index 4816a44c..a8db3c98 100644 --- a/src/chat/sse.ts +++ b/src/chat/sse.ts @@ -2,12 +2,13 @@ import type { ChatWireFrame } from "./types.ts"; export const CHAT_SSE_RETRY_MS = 5000; export const CHAT_SSE_HEARTBEAT_MS = 5000; +export const CHAT_POST_TERMINAL_GRACE_MS = 30_000; export const CHAT_SSE_HEARTBEAT_FRAME = ":ka\n\n"; export const CHAT_STREAM_ENDED_RECOVERY_MESSAGE = "Stream ended before the session completed. Please resend your message."; export const CHAT_TERMINAL_EVENT_TYPES = ["session.done", "session.error", "session.aborted"] as const; -export const CHAT_POST_TERMINAL_NON_RECOVERY_EVENT_TYPES = ["session.suggestion"] as const; +export const CHAT_POST_TERMINAL_NON_RECOVERY_EVENT_TYPES = ["session.suggestion", "session.title_updated"] as const; export const CHAT_SSE_HEADERS = { "Content-Type": "text/event-stream", diff --git a/src/chat/stream-sse.ts b/src/chat/stream-sse.ts index b4ade6cb..c684b7fb 100644 --- a/src/chat/stream-sse.ts +++ b/src/chat/stream-sse.ts @@ -1,4 +1,5 @@ import { + CHAT_POST_TERMINAL_GRACE_MS, CHAT_SSE_RETRY_MS, closeSseController, createSseCloseScheduler, @@ -12,11 +13,17 @@ import type { ChatSessionWriter } from "./writer.ts"; export function createSSEStream(sessionId: string, streamBus: StreamBus, writer: ChatSessionWriter): ReadableStream { let unsub: (() => void) | null = null; let heartbeatCleanup: (() => void) | null = null; + let postTerminalTimer: ReturnType | null = null; + let waitingForPostTerminal = false; const cleanupStream = (): void => { unsub?.(); unsub = null; heartbeatCleanup?.(); heartbeatCleanup = null; + if (postTerminalTimer) { + clearTimeout(postTerminalTimer); + postTerminalTimer = null; + } }; return new ReadableStream({ @@ -38,17 +45,32 @@ export function createSSEStream(sessionId: string, streamBus: StreamBus, writer: heartbeatCleanup?.(); heartbeatCleanup = null; }, close); + const finishPostTerminal = (): void => { + waitingForPostTerminal = false; + if (postTerminalTimer) { + clearTimeout(postTerminalTimer); + postTerminalTimer = null; + } + closeSoon(); + }; write(`retry: ${CHAT_SSE_RETRY_MS}\n\n`); unsub = streamBus.subscribe(sessionId, (frame, seq) => { write(formatSSE(frame, seq)); if (isTerminalChatEvent(frame.event)) { - closeSoon(); + waitingForPostTerminal = true; + if (postTerminalTimer) clearTimeout(postTerminalTimer); + postTerminalTimer = setTimeout(finishPostTerminal, CHAT_POST_TERMINAL_GRACE_MS); + } + if (frame.event === "session.title_updated") { + finishPostTerminal(); } }); - heartbeatCleanup = startSseHeartbeat(write, () => writer.isActive, { onStop: closeSoon }); + heartbeatCleanup = startSseHeartbeat(write, () => writer.isActive || waitingForPostTerminal, { + onStop: closeSoon, + }); }, cancel() { cleanupStream(); diff --git a/src/chat/types-tool.ts b/src/chat/types-tool.ts index 76c71acc..bb6a0d0c 100644 --- a/src/chat/types-tool.ts +++ b/src/chat/types-tool.ts @@ -15,6 +15,7 @@ import type { SessionResumedFrame, SessionStatusFrame, SessionSuggestionFrame, + SessionTitleUpdatedFrame, SessionTruncatedBacklogFrame, TextDeltaFrame, TextEndFrame, @@ -124,6 +125,7 @@ export type SubagentEndFrame = { export type ChatWireFrame = | SessionCreatedFrame + | SessionTitleUpdatedFrame | SessionResumedFrame | SessionCaughtUpFrame | SessionDoneFrame diff --git a/src/chat/types.ts b/src/chat/types.ts index d20c59be..794f4e57 100644 --- a/src/chat/types.ts +++ b/src/chat/types.ts @@ -48,6 +48,13 @@ export type SessionCreatedFrame = { seq: number; }; +export type SessionTitleUpdatedFrame = { + event: "session.title_updated"; + session_id: string; + title: string; + updated_at: string; +}; + export type SessionResumedFrame = { event: "session.resumed"; session_id: string; diff --git a/src/chat/writer.ts b/src/chat/writer.ts index 1df0062e..74e1fbc8 100644 --- a/src/chat/writer.ts +++ b/src/chat/writer.ts @@ -179,13 +179,20 @@ export class ChatSessionWriter { this.deps.sessionStore.incrementMessageCount(this.deps.sessionId); this.deps.sessionStore.updateCost(this.deps.sessionId, response.cost); - // Fire auto-rename after first turn (non-blocking) - autoRenameSession(this.deps.runtime, this.deps.sessionStore, this.deps.sessionId, userText, resultText).catch( - (err: unknown) => { + autoRenameSession(this.deps.runtime, this.deps.sessionStore, this.deps.sessionId, userText, resultText) + .then((renamedTitle) => { + if (!renamedTitle) return; + this.emitFrameAtNextSeq({ + event: "session.title_updated", + session_id: this.deps.sessionId, + title: renamedTitle, + updated_at: new Date().toISOString(), + }); + }) + .catch((err: unknown) => { const msg = err instanceof Error ? err.message : String(err); console.warn(`[chat-writer] Auto-rename failed: ${msg}`); - }, - ); + }); // Fire push notification trigger (non-blocking) if (this.deps.notificationTriggers) { @@ -276,6 +283,10 @@ export class ChatSessionWriter { return seq; } + private emitFrameAtNextSeq(frame: ChatWireFrame): number { + return this.emitFrame(frame, { current: this.deps.eventLog.getMaxSeq(this.deps.sessionId) }); + } + private persistTimeline(timeline: DurableRunTimelineBuilder): void { this.deps.timelineStore?.upsert(timeline.toUpsertParams()); } diff --git a/src/core/server.ts b/src/core/server.ts index 9dcc7afb..e77f43f7 100644 --- a/src/core/server.ts +++ b/src/core/server.ts @@ -306,7 +306,7 @@ export function startServer(config: PhantomConfig, startedAt: number): ReturnTyp return new Response("Method not allowed", { status: 405, headers: { Allow: "GET" } }); } - if (url.pathname.startsWith("/chat") && chatHandler) { + if (isChatRequestPath(url.pathname) && chatHandler) { const response = await chatHandler(req); if (response) return response; } @@ -335,6 +335,10 @@ export function startServer(config: PhantomConfig, startedAt: number): ReturnTyp return server; } +function isChatRequestPath(pathname: string): boolean { + return pathname.startsWith("/chat") || pathname === "/s" || pathname.startsWith("/s/") || pathname === "/new"; +} + async function handlePublicRequest(url: URL): Promise { const publicRoot = pathResolve(getPublicDir(), "public"); const isRoot = url.pathname === "/public" || url.pathname === "/public/";