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();
+ }}
+ >
+
+
+ {!selected && (
+
+
+
+
+ {projects.loading && !projects.data && (
+
+ )}
+ {projects.error && (
+
void projects.reload()}
+ />
+ )}
+ {projects.data && !projects.data.length ? (
+
+ No projects are available. Create a project to start a
+ project chat.
+
+ ) : (
+
+ Choose a project to ask about its tasks and pages.
+
+ )}
+
+
+ )}
+ {sessions.map((session) => {
+ const current = projects.data?.find(
+ (project) => project.id === session.project.id,
+ );
+ const project = current ?? session.project;
+ const unavailable = !!projects.data && !current;
+ return (
+
+ void projects.reload()}
+ />
+
+ );
+ })}
+
+
+
+ );
+}
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 && (
)}
-
+
void refreshControls()}
/>
)}
- {deliveryError && (
+ {!embedded && deliveryError && (
void refreshDelivery()}
/>
)}
- {deliveryAvailable === false && (
+ {!embedded && deliveryAvailable === false && (
Conversation notifications are unavailable on this server.
@@ -463,7 +501,7 @@ function AssistantWorkspace() {
)}
- {assistant.scope?.disabled && (
+ {scopeDisabled && (
Assistant access is off for this project. Choose another scope or
change its project settings.
@@ -474,8 +512,20 @@ function AssistantWorkspace() {
assistant={assistant}
timezone={zone}
onOpenChat={openChat}
- covered={!!panel || upcoming}
- emptyState={scopePicker}
+ covered={!!panel || upcoming || !!embedded?.collapsed}
+ emptyState={
+ embedded ? (
+
+ Ask about this project
+
+ Tasks, pages and linked work in{" "}
+ {assistant.scope?.name ?? "this project"}.
+
+
+ ) : (
+ scopePicker
+ )
+ }
/>
{assistant.progress?.state === "waiting" && (
@@ -550,28 +600,24 @@ function AssistantWorkspace() {
)}
-