From 92900ca8c68d6d67876218c95961d16e2f6f0c32 Mon Sep 17 00:00:00 2001 From: Luan Nguyen Date: Wed, 4 Nov 2026 22:05:28 +1100 Subject: [PATCH 01/10] feat(web): add workspace project chat dock --- .gitignore | 3 + web/src/app/workspace/Chrome.tsx | 5 +- web/src/app/workspace/ProjectChat.tsx | 412 ++++++++++++++++++ web/src/app/workspace/controller.ts | 32 +- web/src/screens/assistant/index.tsx | 92 +++- web/src/screens/assistant/useAssistant.ts | 23 +- web/src/screens/projects/Chats.tsx | 9 +- web/src/screens/projects/Detail.tsx | 29 +- web/src/screens/projects/ProjectChatDock.tsx | 202 +++++++++ .../screens/projects/project-chat-dock.css | 177 ++++++++ web/tests/workspace-chat-guards.test.ts | 118 +++++ 11 files changed, 1055 insertions(+), 47 deletions(-) create mode 100644 web/src/app/workspace/ProjectChat.tsx create mode 100644 web/src/screens/projects/ProjectChatDock.tsx create mode 100644 web/src/screens/projects/project-chat-dock.css create mode 100644 web/tests/workspace-chat-guards.test.ts diff --git a/.gitignore b/.gitignore index 4cc47a1c3..f552e9bf7 100644 --- a/.gitignore +++ b/.gitignore @@ -28,3 +28,6 @@ backups/ # Downloaded source and research references remain local-only. /orbyn-references/ + +# Local collaboration handoffs. +.collab/tasks/ diff --git a/web/src/app/workspace/Chrome.tsx b/web/src/app/workspace/Chrome.tsx index 8c83e2a85..71c7f37d0 100644 --- a/web/src/app/workspace/Chrome.tsx +++ b/web/src/app/workspace/Chrome.tsx @@ -7,6 +7,7 @@ import { } from "../../lib/route-scope"; import { Shell } from "../Shell"; import { WorkspaceController } from "./controller"; +import { WorkspaceProjectChat } from "./ProjectChat"; const inactive = { pathname: "/app", search: "", @@ -88,7 +89,9 @@ export function WorkspaceChrome({ controller={controller} origin={working?.lease} > - {children} + + {children} + ); // A closed/missing dock origin must never silently reroute an action to primary. diff --git a/web/src/app/workspace/ProjectChat.tsx b/web/src/app/workspace/ProjectChat.tsx new file mode 100644 index 000000000..856f6a887 --- /dev/null +++ b/web/src/app/workspace/ProjectChat.tsx @@ -0,0 +1,412 @@ +import { newId, type Project } from "@orbyn/core"; +import { MessageSquare, Minus } from "lucide-react"; +import { + createContext, + useCallback, + useContext, + useEffect, + useMemo, + useRef, + useState, + type ReactNode, +} from "react"; +import { usePath } from "../../lib/router"; +import { + RouteScopeProvider, + useRouteScope, + type RouteScope, + type RouteSnapshot, +} from "../../lib/route-scope"; +import { useResource } from "../../lib/resource"; +import { client } from "../../lib/session"; +import { watchAccount } from "../../lib/account-events"; +import { + ErrorNotice, + IconButton, + Loading, + SelectBox, + useToast, +} from "../../ui/kit"; +import { + ProjectChatDock, + type ProjectChatRequest, +} from "../../screens/projects/ProjectChatDock"; +import type { WorkspaceController } from "./controller"; + +type ChatRequest = Omit; +const Context = createContext< + ((project: Project, request?: ChatRequest) => void) | null +>(null); +export function useProjectChat() { + const open = useContext(Context); + if (!open) throw new Error("Project chat needs the authenticated workspace."); + return open; +} + +type Session = { project: Project; request: ProjectChatRequest | null }; + +function SessionScope({ + scope, + active, + children, +}: { + scope: RouteScope; + active: boolean; + children: ReactNode; +}) { + const activeRef = useRef(active); + activeRef.current = active; + const value = useMemo(() => { + const visible = { + ...scope.getSnapshot(), + visible: true, + focused: true, + }; + const hidden = { ...visible, visible: false, focused: false }; + const listeners = new Set<() => void>(); + let disposing = false; + const notify = () => listeners.forEach((listener) => listener()); + return { + ...scope, + subscribe: (listener) => { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; + }, + getSnapshot: () => (disposing || activeRef.current ? visible : hidden), + // Disposal can ask about a hidden project's draft. Its confirmation is + // an explicit global action, while ordinary hidden input stays disabled. + addGuard: (guard) => + scope.addGuard(async (destination) => { + disposing = true; + notify(); + try { + return await guard(destination); + } finally { + disposing = false; + notify(); + } + }), + }; + }, [scope]); + return {children}; +} + +/** The authenticated shell owns sessions, independently of retained working tabs. */ +export function WorkspaceProjectChat({ + controller, + children, +}: { + controller: WorkspaceController; + children: ReactNode; +}) { + const route = useRouteScope(); + const routeRef = useRef(route); + routeRef.current = route; + const path = usePath(); + const toast = useToast(); + const projects = useResource(() => client.listProjects(), []); + const [selected, setSelected] = useState(""); + const [sessions, setSessions] = useState([]); + const [open, setOpen] = useState(false); + const [runningProjects, setRunningProjects] = useState([]); + const busyProjects = useRef(new Set()); + const availableProjects = useRef(projects.data); + availableProjects.current = projects.data; + const hasBlockingRun = useCallback( + () => + [...busyProjects.current].some( + (id) => + !availableProjects.current || + availableProjects.current.some((project) => project.id === id), + ), + [], + ); + const busy = runningProjects.some( + (id) => + !projects.data || projects.data.some((project) => project.id === id), + ); + const launcher = useRef(null); + const anchor = useRef(null); + const returnFocus = useRef(false); + const panelId = "workspace-project-chat"; + + const selectProject = useCallback( + (project: Project) => { + if (hasBlockingRun()) { + toast({ + text: "Stop the active project run before switching projects.", + }); + return; + } + setSelected(project.id); + setSessions((current) => + current.some((session) => session.project.id === project.id) + ? current + : [...current, { project, request: null }], + ); + }, + [toast, hasBlockingRun], + ); + + const requestChat = useCallback( + (project: Project, request: ChatRequest = {}) => { + setOpen(true); + if (hasBlockingRun()) { + toast({ + text: "This project chat is still working. Stop its run before opening another chat.", + }); + return; + } + setSelected(project.id); + setSessions((current) => { + const next = { project, request: { ...request, id: newId() } }; + return current.some((session) => session.project.id === project.id) + ? current.map((session) => + session.project.id === project.id ? next : session, + ) + : [...current, next]; + }); + }, + [toast, hasBlockingRun], + ); + + // Select an initial project only. Navigation never retargets an existing chat. + useEffect(() => { + if (selected || !projects.data) return; + const match = /^\/app\/project\/([^/]+)$/.exec(path); + const project = projects.data.find((item) => item.id === match?.[1]); + if (project) selectProject(project); + }, [path, projects.data, selected, selectProject]); + + useEffect(() => { + const refresh = () => void projects.reload(); + window.addEventListener("focus", refresh); + return () => window.removeEventListener("focus", refresh); + }, [projects.reload]); + + useEffect(() => { + let timer: ReturnType; + const refresh = () => { + clearTimeout(timer); + timer = setTimeout(() => void projects.reload(), 400); + }; + const stop = watchAccount((news) => { + if ( + news.kind === "changed" && + (!news.area || ["projects", "organize"].includes(news.area)) + ) + refresh(); + }, refresh); + return () => { + clearTimeout(timer); + stop(); + }; + }, [projects.reload]); + + const onBusy = useCallback((projectId: string, value: boolean) => { + if (value) busyProjects.current.add(projectId); + else busyProjects.current.delete(projectId); + setRunningProjects([...busyProjects.current]); + }, []); + const collapse = useCallback(() => { + returnFocus.current = true; + setOpen(false); + }, []); + useEffect(() => { + if (!open && returnFocus.current) { + returnFocus.current = false; + launcher.current?.focus(); + } + }, [open]); + useEffect(() => { + if (open && !selected) anchor.current?.querySelector("select")?.focus(); + }, [open, selected, projects.loading]); + useEffect(() => { + if (!open) return; + const revealFocus = (event: FocusEvent) => { + const target = event.target; + if ( + !(target instanceof HTMLElement) || + anchor.current?.contains(target) || + target.closest('[role="dialog"]') + ) + return; + const panel = anchor.current?.querySelector( + ".pj-chat-panel:not([hidden])", + ); + if (!panel) return; + const a = panel.getBoundingClientRect(), + b = target.getBoundingClientRect(); + if ( + a.left < b.right && + a.right > b.left && + a.top < b.bottom && + a.bottom > b.top + ) + setOpen(false); + }; + document.addEventListener("focusin", revealFocus); + return () => document.removeEventListener("focusin", revealFocus); + }, [open]); + + const scope = useMemo(() => { + const snapshot: RouteSnapshot = { + pathname: "/app", + search: "", + hash: "", + activation: 0, + visible: true, + focused: true, + }; + return { + subscribe: () => () => {}, + getSnapshot: () => snapshot, + navigate: async (href, replace) => + routeRef.current + ? routeRef.current.navigate(href, replace) + : { + kind: "refused", + reason: "Choose a workspace tab before opening Assistant.", + }, + addGuard: (guard) => controller.registerWorkspaceGuard(guard), + noteInput: () => controller.noteWorkspaceInput(), + setTitle: () => () => {}, + }; + }, [controller]); + + const picker = ( + { + const project = projects.data?.find( + (item) => item.id === event.target.value, + ); + if (project) selectProject(project); + }} + > + + {projects.data?.map((project) => ( + + ))} + {selected && + projects.data && + !projects.data.some((project) => project.id === selected) && ( + + )} + + ); + + return ( + + {children} +
controller.noteWorkspaceInput()} + onClickCapture={() => controller.noteWorkspaceInput()} + onKeyDown={(event) => { + if ( + event.key !== "Escape" || + event.defaultPrevented || + (event.target as HTMLElement).closest('[role="dialog"]') + ) + return; + event.preventDefault(); + event.stopPropagation(); + collapse(); + }} + > + + +
+
+ ); +} diff --git a/web/src/app/workspace/controller.ts b/web/src/app/workspace/controller.ts index e3a126ccf..89ca785f0 100644 --- a/web/src/app/workspace/controller.ts +++ b/web/src/app/workspace/controller.ts @@ -54,7 +54,12 @@ export type WorkspaceSnapshot = Readonly<{ storageError: string; }>; export type PrepareReason = - "replace" | "close" | "unpair" | "reload" | "sign-out" | "leave-workspace"; + | "replace" + | "close" + | "unpair" + | "reload" + | "sign-out" + | "leave-workspace"; export type FrameOwner = { prepare(reason: PrepareReason, destination: string): Promise; /** A modal must block parking its owner even when its form is clean. */ @@ -123,6 +128,7 @@ const superseded = (): RouteResult => ({ kind: "superseded" }); export class WorkspaceController implements BrowserNavigationOwner { private state: WorkspaceSnapshot; private readonly listeners = new Set<() => void>(); + private readonly workspaceGuards = new Set(); private readonly registry = new Map(); private readonly scopeCache = new Map(); private readonly inputEpochs = new Map(); @@ -899,6 +905,19 @@ export class WorkspaceController implements BrowserNavigationOwner { ); } + /** Persistent shell owners outlive working frames and guard explicit disposal. */ + registerWorkspaceGuard(guard: LeaveGuard) { + this.workspaceGuards.add(guard); + return () => { + this.workspaceGuards.delete(guard); + }; + } + + /** Authored shell input invalidates an in-flight global disposal decision. */ + noteWorkspaceInput() { + this.intent++; + } + /** Explicit global Reload/signout admission. Caller must execute before another intent. */ async prepareAll( reason: "reload" | "sign-out" | "leave-workspace", @@ -906,12 +925,15 @@ export class WorkspaceController implements BrowserNavigationOwner { const opening = this.state; const ticket = ++this.intent; const registrations = new Map(); + const workspaceGuards = [...this.workspaceGuards]; let global = { allowed: true, current: () => true }; const current = () => this.current() && global.current() && this.intent === ticket && this.state.revision === opening.revision && + workspaceGuards.length === this.workspaceGuards.size && + workspaceGuards.every((guard) => this.workspaceGuards.has(guard)) && [...registrations].every( ([key, token]) => this.registry.get(key)?.token === token, ); @@ -921,6 +943,14 @@ export class WorkspaceController implements BrowserNavigationOwner { return { allowed: false, current }; } if (!global.allowed || !current()) return { allowed: false, current }; + for (const guard of workspaceGuards) { + try { + if (!(await guard("")) || !current()) + return { allowed: false, current }; + } catch { + return { allowed: false, current }; + } + } for (const frame of opening.frames) { const registration = this.registry.get(leaseKey(frame.lease)); if (!registration) return { allowed: false, current }; diff --git a/web/src/screens/assistant/index.tsx b/web/src/screens/assistant/index.tsx index 93adf316c..b5bec09f5 100644 --- a/web/src/screens/assistant/index.tsx +++ b/web/src/screens/assistant/index.tsx @@ -12,7 +12,7 @@ import { Square, User, } from "lucide-react"; -import { useEffect, useState } from "react"; +import { useEffect, useId, useState } from "react"; import { client } from "../../lib/session"; import { useNavigate, useSearch, useTitle } from "../../lib/router"; import { @@ -77,13 +77,37 @@ export default function AssistantScreen() { useTitle("Assistant"); return ; } -function AssistantWorkspace() { +type EmbeddedAssistant = { + projectId: string; + query: string; + collapsed: boolean; + disabled: boolean; + onChat: (id: string) => void; + onBusy: (busy: boolean) => void; +}; + +export function AssistantWorkspace({ + embedded, +}: { embedded?: EmbeddedAssistant } = {}) { + const instanceId = useId(); + const messageId = embedded + ? `project-message-${instanceId}` + : "assistant-message"; + const limitId = embedded ? `project-limit-${instanceId}` : "assistant-limit"; const tasks = useTasks(); const navigate = useNavigate(); const confirm = useConfirm(); const owned = useOwned(), - query = useSearch(), - assistant = useAssistant(query), + routeQuery = useSearch(), + query = embedded?.query ?? routeQuery, + assistant = useAssistant( + query, + embedded && { + onChat: embedded.onChat, + retainPath: `/app/project/${embedded.projectId}`, + disabled: embedded.disabled, + }, + ), projects = useRead(() => client.listProjects()), prefs = useRead(() => client.getPlannerPrefs()), capabilities = useRead(() => client.aiCapabilities()), @@ -103,6 +127,9 @@ function AssistantWorkspace() { [upcoming, setUpcoming] = useState( () => new URLSearchParams(query).get("panel") === "upcoming", ); + useEffect(() => { + embedded?.onBusy(assistant.sending); + }, [embedded?.onBusy, assistant.sending]); useEffect(() => { setUpcoming(new URLSearchParams(query).get("panel") === "upcoming"); }, [query]); @@ -126,6 +153,10 @@ function AssistantWorkspace() { } setPanel(null); setUpcoming(false); + if (embedded) { + embedded.onChat(id); + return; + } if (narrow()) setRail(false); navigate(`/app/assistant?chat=${encodeURIComponent(id)}`); }; @@ -175,12 +206,15 @@ function AssistantWorkspace() { } }; const overLimit = assistant.message.trim().length > 4000; + const scopeDisabled = embedded + ? embedded.disabled + : !!assistant.scope?.disabled; const disabled = overLimit || assistant.loading || !assistant.ready || assistant.sending || - !!assistant.scope?.disabled || + scopeDisabled || !!assistant.replyError || (!!assistant.reply && !assistant.replyEntry); const zone = prefs.data?.timezone ?? timezone(); @@ -247,9 +281,13 @@ function AssistantWorkspace() {

); + const Container = embedded ? "div" : "main"; return ( -
- {rail && ( + + {!embedded && rail && ( )}
-
+