diff --git a/frontend/src/adapters/http/agentGateway.test.ts b/frontend/src/adapters/http/agentGateway.test.ts index b69dcffb..8d2ceae5 100644 --- a/frontend/src/adapters/http/agentGateway.test.ts +++ b/frontend/src/adapters/http/agentGateway.test.ts @@ -211,4 +211,119 @@ describe("HttpAgentGateway WS round-trip (B6 frames)", () => { })); await expect(resPromise).rejects.toMatchObject({ code: "NOT_FOUND" }); }); + + it("routes structured chat through chat.* frames and HTTP close/cancel commands", async () => { + const calls: { url: string; init: unknown }[] = []; + const socket = new FakeSocket(); + const ws = new WsLiveClient({ + wsUrl: "wss://host", + socketFactory: () => { + queueMicrotask(() => socket.onopen?.()); + return socket; + }, + }); + const gw = new HttpAgentGateway( + new HttpInvoker({ + baseUrl: "https://host", + fetchImpl: async (url, init) => { + calls.push({ url, init }); + return { + ok: true, + status: 200, + json: async () => undefined, + text: async () => "", + }; + }, + }), + ws, + ); + + const launched = gw.launchAgentChat("proj-1", "agent-1", { + cwd: "/srv/app", + nodeId: "node-1", + rows: 24, + cols: 80, + conversationId: "resume-9", + }); + const launch = await replyToLast(socket, 0, (id) => ({ + kind: "chat.attached", + replyTo: id, + payload: { + sessionId: "chat-1", + cellKind: "chat", + assignedConversationId: "conv-1", + }, + })); + await expect(launched).resolves.toEqual({ + sessionId: "chat-1", + cellKind: "chat", + assignedConversationId: "conv-1", + }); + expect(launch.kind).toBe("agent.launch"); + expect(launch.payload).toEqual({ + projectId: "proj-1", + agentId: "agent-1", + nodeId: "node-1", + rows: 24, + cols: 80, + cellKind: "chat", + conversationId: "resume-9", + freshStart: false, + }); + + const reattached = gw.reattachAgentChat("chat-1", () => {}); + const attach = await replyToLast(socket, 1, (id) => ({ + kind: "chat.attached", + replyTo: id, + payload: { + sessionId: "chat-1", + scrollback: [{ kind: "final", content: "Recovered reply" }], + }, + })); + await expect(reattached).resolves.toEqual({ + sessionId: "chat-1", + scrollback: [{ kind: "final", content: "Recovered reply" }], + }); + expect(attach.kind).toBe("chat.attach"); + expect(attach.payload).toEqual({ sessionId: "chat-1" }); + + const chunks: unknown[] = []; + const sent = gw.sendAgentChat("chat-1", "Hello", (chunk) => chunks.push(chunk), { + attachments: [{ path: "/tmp/note.txt", filename: "note.txt" }], + }); + const chatSend = await replyToLast(socket, 2, (id) => ({ + kind: "chat.output", + replyTo: id, + payload: { + sessionId: "chat-1", + chunk: { kind: "final", content: "Hello back" }, + }, + })); + await sent; + expect(chatSend.kind).toBe("chat.send"); + expect(chatSend.payload).toEqual({ + sessionId: "chat-1", + prompt: "Hello", + attachments: [{ path: "/tmp/note.txt", filename: "note.txt" }], + }); + expect(chunks).toEqual([{ kind: "final", content: "Hello back" }]); + + await gw.cancelAgentChat("chat-1"); + await gw.closeAgentChat("chat-1"); + await gw.detachAgentChat("chat-1"); + + expect(calls).toHaveLength(2); + expect(JSON.parse((calls[0].init as { body: string }).body)).toEqual({ + command: "cancel_agent_chat", + args: { sessionId: "chat-1" }, + }); + expect(JSON.parse((calls[1].init as { body: string }).body)).toEqual({ + command: "close_agent_session", + args: { sessionId: "chat-1" }, + }); + expect(JSON.parse(socket.sent[3])).toMatchObject({ + kind: "chat.detach", + payload: { sessionId: "chat-1" }, + }); + }); }); diff --git a/frontend/src/adapters/http/frames.ts b/frontend/src/adapters/http/frames.ts index d63e8e21..548e3c67 100644 --- a/frontend/src/adapters/http/frames.ts +++ b/frontend/src/adapters/http/frames.ts @@ -14,6 +14,8 @@ * survives JSON. */ +import type { ReplyChunk } from "@/domain"; + // --------------------------------------------------------------------------- // Base64 helpers (binary PTY bytes over JSON frames) // --------------------------------------------------------------------------- @@ -137,5 +139,18 @@ export interface StatusPayload { /** Payload of a `chat.output` frame (structured assistant stream). */ export interface ChatOutputPayload { sessionId: string; - chunk: unknown; // ReplyChunk-shaped (kind-tagged); normalized by the caller. + chunk: ReplyChunk; +} + +/** Payload of a `chat.attached` acknowledgement. */ +export interface ChatAttachedPayload { + sessionId?: string; + session?: { + sessionId: string; + projectId?: string; + nodeId?: string | null; + }; + cellKind?: "chat" | "pty"; + assignedConversationId?: string; + scrollback?: ReplyChunk[]; } diff --git a/frontend/src/adapters/http/streamGateways.ts b/frontend/src/adapters/http/streamGateways.ts index bc462473..38fc04df 100644 --- a/frontend/src/adapters/http/streamGateways.ts +++ b/frontend/src/adapters/http/streamGateways.ts @@ -25,9 +25,11 @@ import type { AppExitWorkGuardState, DomainEvent, EffortSelection, + ExecuteSlashCommandResult, HealthReport, ReplyChunk, ResumableAgent, + SlashCommand, Sprint, TerminalSession, Ticket, @@ -40,6 +42,7 @@ import type { Unsubscribe, } from "@/domain"; import type { + AgentChatHandle, AgentGateway, ChatDraftScope, ChatDraftSnapshot, @@ -48,7 +51,9 @@ import type { CreateTicketInput, LiveAgent, OpenTerminalOptions, + ReattachAgentChatResult, ReattachResult, + SendAgentChatOptions, StoppedLiveAgent, SystemGateway, TerminalGateway, @@ -59,7 +64,7 @@ import type { } from "@/ports"; import type { HttpInvoker } from "./httpInvoker"; import { WsLiveClient } from "./wsLiveClient"; -import { type ChatOutputPayload } from "./frames"; +import { type ChatAttachedPayload, type ChatOutputPayload } from "./frames"; import { unsupportedOnWeb } from "./unsupported"; /** @@ -96,6 +101,14 @@ export function makeWsTerminalHandle( }; } +function chatSessionId(payload: ChatAttachedPayload): string { + const sessionId = payload.sessionId ?? payload.session?.sessionId; + if (!sessionId) { + throw { code: "INVALID_CHAT_ACK", message: "chat acknowledgement omitted sessionId" }; + } + return sessionId; +} + export class HttpSystemGateway implements SystemGateway { constructor( private readonly http: HttpInvoker, @@ -306,6 +319,114 @@ export class HttpAgentGateway implements AgentGateway { scrollback: res.scrollback, }; } + + async launchAgentChat( + projectId: string, + agentId: string, + options: OpenTerminalOptions, + ): Promise { + const ack = await this.ws.send("agent.launch", { + projectId, + agentId, + nodeId: options.nodeId ?? null, + rows: options.rows, + cols: options.cols, + cellKind: "chat", + conversationId: options.conversationId ?? null, + freshStart: options.freshStart ?? false, + }); + if (ack.kind !== "chat.attached") { + throw { + code: "STRUCTURED_ROUTED_TO_NON_CHAT", + message: `custom CLI launch returned ${ack.kind}; expected chat.attached`, + }; + } + const payload = ack.payload as unknown as ChatAttachedPayload; + if (payload.cellKind && payload.cellKind !== "chat") { + throw { + code: "STRUCTURED_ROUTED_TO_NON_CHAT", + message: `custom CLI launch returned cellKind=${payload.cellKind}; expected chat`, + }; + } + const sessionId = chatSessionId(payload); + return { + sessionId, + cellKind: "chat", + ...(payload.assignedConversationId + ? { assignedConversationId: payload.assignedConversationId } + : {}), + }; + } + + async reattachAgentChat( + sessionId: string, + onChunk: (chunk: ReplyChunk) => void, + ): Promise { + const ack = await this.ws.send("chat.attach", { sessionId }); + if (ack.kind !== "chat.attached") { + throw { + code: "CHAT_ATTACH_FAILED", + message: `chat attach returned ${ack.kind}; expected chat.attached`, + }; + } + const payload = ack.payload as unknown as ChatAttachedPayload; + const attachedSessionId = chatSessionId(payload); + this.ws.setChatSink(attachedSessionId, onChunk); + return { + sessionId: attachedSessionId, + scrollback: payload.scrollback ?? [], + }; + } + + async detachAgentChat(sessionId: string): Promise { + this.ws.removeChatSink(sessionId); + await this.ws.sendFireAndForget("chat.detach", { sessionId }); + } + + async sendAgentChat( + sessionId: string, + prompt: string, + onChunk: (chunk: ReplyChunk) => void, + options: SendAgentChatOptions = {}, + ): Promise { + this.ws.setChatSink(sessionId, onChunk); + const ack = await this.ws.send("chat.send", { + sessionId, + prompt, + attachments: options.attachments ?? [], + }); + const payload = ack.payload as unknown as ChatOutputPayload | undefined; + if (payload?.chunk) onChunk(payload.chunk); + } + + listSlashCommands(prefix?: string): Promise { + const request = prefix === undefined ? {} : { prefix }; + return this.http + .invoke<{ commands: SlashCommand[] }>("list_slash_commands", { request }) + .then((res) => res.commands); + } + + executeSlashCommand( + name: string, + options: { sessionId?: string | null; arguments?: unknown[] } = {}, + ): Promise { + return this.http.invoke("execute_slash_command", { + request: { + name, + sessionId: options.sessionId ?? null, + arguments: options.arguments ?? [], + }, + }); + } + + async closeAgentChat(sessionId: string): Promise { + this.ws.removeChatSink(sessionId); + await this.http.invoke("close_agent_session", { sessionId }); + } + + async cancelAgentChat(sessionId: string): Promise { + await this.http.invoke("cancel_agent_chat", { sessionId }); + } } export class HttpTicketGateway implements TicketGateway { diff --git a/frontend/src/adapters/http/wsLiveClient.chat.test.ts b/frontend/src/adapters/http/wsLiveClient.chat.test.ts new file mode 100644 index 00000000..868c7a2a --- /dev/null +++ b/frontend/src/adapters/http/wsLiveClient.chat.test.ts @@ -0,0 +1,83 @@ +import { describe, it, expect } from "vitest"; + +import type { WebSocketLike } from "./wsLiveClient"; +import { WsLiveClient } from "./wsLiveClient"; + +class FakeSocket implements WebSocketLike { + sent: string[] = []; + onopen: (() => void) | null = null; + onmessage: ((event: { data: string }) => void) | null = null; + onerror: ((event: unknown) => void) | null = null; + onclose: (() => void) | null = null; + + send(data: string): void { + this.sent.push(data); + } + close(): void { + this.onclose?.(); + } + receive(frame: unknown): void { + this.onmessage?.({ data: JSON.stringify(frame) }); + } +} + +function connectedClient(): { client: WsLiveClient; socket: FakeSocket } { + const socket = new FakeSocket(); + const client = new WsLiveClient({ + wsUrl: "wss://host", + socketFactory: () => { + queueMicrotask(() => socket.onopen?.()); + return socket; + }, + }); + return { client, socket }; +} + +describe("WsLiveClient structured chat routing", () => { + it("routes chat.output to the per-session chat sink", async () => { + const { client, socket } = connectedClient(); + await client.ensureConnected(); + + const received: unknown[] = []; + client.setChatSink("chat-1", (chunk) => received.push(chunk)); + + socket.receive({ + kind: "chat.output", + payload: { + sessionId: "chat-1", + chunk: { kind: "final", content: "done" }, + }, + }); + + expect(received).toEqual([{ kind: "final", content: "done" }]); + + client.removeChatSink("chat-1"); + socket.receive({ + kind: "chat.output", + payload: { + sessionId: "chat-1", + chunk: { kind: "final", content: "ignored" }, + }, + }); + expect(received).toEqual([{ kind: "final", content: "done" }]); + }); + + it("clears structured chat sinks on disconnect", async () => { + const { client, socket } = connectedClient(); + await client.ensureConnected(); + + const received: unknown[] = []; + client.setChatSink("chat-1", (chunk) => received.push(chunk)); + client.disconnect(); + expect(client.getConnectionState()).toBe("closed"); + + socket.receive({ + kind: "chat.output", + payload: { + sessionId: "chat-1", + chunk: { kind: "final", content: "dropped" }, + }, + }); + expect(received).toEqual([]); + }); +}); diff --git a/frontend/src/adapters/http/wsLiveClient.ts b/frontend/src/adapters/http/wsLiveClient.ts index 72c20f9e..b2194037 100644 --- a/frontend/src/adapters/http/wsLiveClient.ts +++ b/frontend/src/adapters/http/wsLiveClient.ts @@ -30,11 +30,12 @@ * output sink (a notice line), so no component needs to know about this client. */ -import type { DomainEvent, Unsubscribe } from "@/domain"; +import type { DomainEvent, ReplyChunk, Unsubscribe } from "@/domain"; import { base64ToBytes, bytesToBase64, type AttachedPayload, + type ChatOutputPayload, type ClientFrame, type ClientFrameKind, type OutputPayload, @@ -167,6 +168,8 @@ export class WsLiveClient { /** Per-session PTY output sinks (sessionId → onData). Low-level routing map. */ private readonly outputSinks = new Map void>(); + /** Per-session structured chat sinks (sessionId → onChunk). */ + private readonly chatSinks = new Map void>(); /** Live terminal subscriptions tracked for reconnection replay + status. */ private readonly terminals = new Map(); /** Pending command acknowledgements, keyed by client frame id. */ @@ -236,6 +239,12 @@ export class WsLiveClient { removeOutputSink(sessionId: string): void { this.outputSinks.delete(sessionId); } + setChatSink(sessionId: string, onChunk: (chunk: ReplyChunk) => void): void { + this.chatSinks.set(sessionId, onChunk); + } + removeChatSink(sessionId: string): void { + this.chatSinks.delete(sessionId); + } // ------------------------------------------------------------------------- // Connection lifecycle @@ -518,6 +527,7 @@ export class WsLiveClient { } this.rejectAllPending({ code: "WS_CLOSED", message: "client disconnected" }); this.outputSinks.clear(); + this.chatSinks.clear(); this.terminals.clear(); this.domainEventHandler = null; const socket = this.socket; @@ -544,6 +554,7 @@ export class WsLiveClient { } this.rejectAllPending({ code: "WS_CLOSED", message: "client disposed" }); this.outputSinks.clear(); + this.chatSinks.clear(); this.terminals.clear(); this.domainEventHandler = null; this.socket?.close(); @@ -565,27 +576,36 @@ export class WsLiveClient { return; // Ignore malformed frames. } - // Route unsolicited streams first (no replyTo). - switch (frame.kind) { - case "terminal.output": { - const payload = frame.payload as unknown as OutputPayload; - const sub = this.terminals.get(payload.sessionId); - if (sub && typeof payload.seq === "number") sub.lastSeq = payload.seq; - const sink = this.outputSinks.get(payload.sessionId); - if (sink) sink(base64ToBytes(payload.bytesBase64)); - return; + // Route unsolicited streams first. Frames carrying `replyTo` are command + // acknowledgements and must fall through to the pending waiter below; in + // particular `chat.send` may acknowledge with a `chat.output` frame. + if (!frame.replyTo) { + switch (frame.kind) { + case "terminal.output": { + const payload = frame.payload as unknown as OutputPayload; + const sub = this.terminals.get(payload.sessionId); + if (sub && typeof payload.seq === "number") sub.lastSeq = payload.seq; + const sink = this.outputSinks.get(payload.sessionId); + if (sink) sink(base64ToBytes(payload.bytesBase64)); + return; + } + case "terminal.status": { + const payload = frame.payload as unknown as StatusPayload; + this.handleStatus(payload); + return; + } + case "event.domain": { + this.domainEventHandler?.(frame.payload as unknown as DomainEvent); + return; + } + case "chat.output": { + const payload = frame.payload as unknown as ChatOutputPayload; + this.chatSinks.get(payload.sessionId)?.(payload.chunk); + return; + } + default: + break; } - case "terminal.status": { - const payload = frame.payload as unknown as StatusPayload; - this.handleStatus(payload); - return; - } - case "event.domain": { - this.domainEventHandler?.(frame.payload as unknown as DomainEvent); - return; - } - default: - break; } // Otherwise it is a reply/ack correlated by `replyTo`. diff --git a/frontend/src/features/web/WebDesktopWorkspace.test.tsx b/frontend/src/features/web/WebDesktopWorkspace.test.tsx new file mode 100644 index 00000000..3f680310 --- /dev/null +++ b/frontend/src/features/web/WebDesktopWorkspace.test.tsx @@ -0,0 +1,107 @@ +import { beforeEach, describe, it, expect } from "vitest"; +import { fireEvent, render, screen, within } from "@testing-library/react"; + +import type { Gateways } from "@/ports"; +import { DIProvider } from "@/app/di"; +import { createMockGateways, MockWorkStateGateway } from "@/adapters/mock"; +import { WebSession } from "@/adapters/http"; +import type { FetchLike } from "@/adapters/http/httpInvoker"; +import type { FlagStore } from "@/adapters/http/webSession"; +import { WebApp } from "./WebApp"; + +function memStore(): FlagStore { + const map = new Map(); + return { + getItem: (k) => map.get(k) ?? null, + setItem: (k, v) => void map.set(k, v), + removeItem: (k) => void map.delete(k), + }; +} + +const okFetch: FetchLike = async () => ({ + ok: true, + status: 200, + json: async () => ({ ok: true }), + text: async () => "{}", +}); + +async function seededGateways(): Promise { + const gateways = createMockGateways(); + const project = await gateways.project.createProject("Demo", "/srv/demo"); + (gateways.workState as MockWorkStateGateway)._setProjectWorkState(project.id, { + agents: [ + { agentId: "a1", name: "Archi", profileId: "p1", busy: { state: "idle" }, tickets: [] }, + ], + conversations: [], + }); + return gateways; +} + +function renderPairedWebApp(gateways: Gateways) { + const session = new WebSession({ baseUrl: "https://h", fetchImpl: okFetch, store: memStore() }); + session.markPaired(); + return render( + + + , + ); +} + +beforeEach(() => { + window.localStorage.clear(); + window.innerWidth = 1280; + window.dispatchEvent(new Event("resize")); +}); + +describe("WebWorkspace desktop layout", () => { + it("mounts the desktop AppImage-style shell with LayoutTabs/LayoutGrid and no detached action", async () => { + renderPairedWebApp(await seededGateways()); + + expect( + (await screen.findByTestId("web-workspace")).getAttribute("data-shell"), + ).toBe( + "desktop-layout", + ); + expect(screen.queryByTestId("web-mobile-task-canvas")).toBeNull(); + + fireEvent.click(await screen.findByText("Demo")); + + expect(await screen.findByTestId("layout-tabs")).toBeTruthy(); + expect(await screen.findByTestId("layout-grid")).toBeTruthy(); + expect(screen.getByLabelText("right dock")).toBeTruthy(); + expect(screen.queryByRole("button", { name: /detach/i })).toBeNull(); + expect(screen.queryByText(/Fenêtre/)).toBeNull(); + }); + + it("opens panels as floating or docked from the desktop menu bar without a detached web mode", async () => { + renderPairedWebApp(await seededGateways()); + fireEvent.click(await screen.findByText("Demo")); + await screen.findByTestId("layout-grid"); + + fireEvent.click(screen.getByRole("button", { name: "Panneaux" })); + const menu = await screen.findByRole("menu", { name: "Panneaux" }); + + fireEvent.click(within(menu).getByRole("button", { name: "Tickets" })); + const submenu = await screen.findByRole("menu", { name: "Tickets" }); + fireEvent.click(within(submenu).getByRole("button", { name: "Flottant" })); + + expect(await screen.findByRole("dialog", { name: "Tickets" })).toBeTruthy(); + expect(screen.queryByRole("button", { name: /detached/i })).toBeNull(); + }); + + it("can dock a panel to the left from the menu bar while keeping the grid in the main area", async () => { + renderPairedWebApp(await seededGateways()); + fireEvent.click(await screen.findByText("Demo")); + await screen.findByTestId("layout-grid"); + + fireEvent.click(screen.getByRole("button", { name: "Panneaux" })); + const menu = await screen.findByRole("menu", { name: "Panneaux" }); + fireEvent.click(within(menu).getByRole("button", { name: "Project context" })); + const submenu = await screen.findByRole("menu", { name: "Project context" }); + fireEvent.click(within(submenu).getByRole("button", { name: "Ancré à gauche" })); + + expect(await screen.findByLabelText("left dock")).toBeTruthy(); + expect(await screen.findByLabelText("Project context panel")).toBeTruthy(); + expect(screen.getByTestId("layout-grid")).toBeTruthy(); + }); +}); diff --git a/frontend/src/features/web/WebWorkspace.tsx b/frontend/src/features/web/WebWorkspace.tsx index 0e587b02..83c30644 100644 --- a/frontend/src/features/web/WebWorkspace.tsx +++ b/frontend/src/features/web/WebWorkspace.tsx @@ -3,43 +3,65 @@ * and #203/#204 (desktop shell parity + browser-openable surfaces). * * After pairing: list/create projects (web variant — no native folder browse, - * see {@link WebProjectsPanel}), open one read-only, and route the main surface - * through the **"Panneaux" menu** (#90) across the transport-neutral panels also - * used by the desktop `ProjectsView` (context/tickets/sprints/agents/templates/ - * skills/permissions/memory/git/projects) plus the web-specific live work-state - * view below. Selecting a panel replaces the main surface; project-scoped - * surfaces can also be opened into browser tabs/windows (#204), without using - * the desktop-only Tauri window gateway. + * see {@link WebProjectsPanel}), open one project, and split the shell by + * viewport. Desktop web reuses the AppImage layout system (`LayoutTabs`, + * `LayoutGrid`, floating/docked panels) without OS-detached windows. Mobile web + * keeps the simplified task-flow shell. * * The live work-state ("Travail") panel keeps its own bespoke, mobile-adapted * implementation rather than the desktop `ProjectWorkStatePanel`: that component's - * "open" affordance attaches into a `LayoutGrid` cell, which the web client never - * mounts (#69 pins this — no `layout-*` grid on web). This panel's own "Ouvrir" - * instead mounts {@link WebAgentCell}, a touch-drivable terminal cell (F4) — the - * mobile equivalent, kept transport-neutral via the same {@link useProjectWorkState} - * hook and {@link WorkStateGateway} writes (cancel/retry). {@link useLiveReconnect} - * re-synchronises the read-model after a WS outage. + * "open" affordance attaches into a `LayoutGrid` cell on desktop. The mobile + * panel's own "Ouvrir" still mounts {@link WebAgentCell}, a touch-drivable + * terminal cell (F4), kept transport-neutral via the same + * {@link useProjectWorkState} hook and {@link WorkStateGateway} writes + * (cancel/retry). {@link useLiveReconnect} re-synchronises the read-model after + * a WS outage. */ -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { useCallback, useEffect, useMemo, useState } from "react"; import type { AgentWorkState, BackgroundCompletion, GatewayError, + LayoutInfo, Project, } from "@/domain"; import { useGateways } from "@/app/di"; import { useProjectWorkState } from "@/features/workstate/useProjectWorkState"; +import { LayoutGrid, LayoutTabs } from "@/features/layout"; import { ProjectContextPanel } from "@/features/projects/ProjectContextPanel"; +import { + PANEL_TITLE, + floatingPanels, + isDockedTo, + panelsDockedTo, + placementOf, + type PanelId, + type ViewPlacement, + type ViewPlacements, +} from "@/features/projects/viewPlacement"; import { AgentsPanel } from "@/features/agents"; import { TemplatesPanel } from "@/features/templates"; import { SkillsPanel } from "@/features/skills"; import { PermissionsPanel } from "@/features/permissions"; import { MemoryPanel } from "@/features/memory"; import { EmbedderSettings } from "@/features/embedder"; -import { GitPanel } from "@/features/git"; -import { Button, Panel, Spinner, cn } from "@/shared"; +import { GitGraphView, GitPanel } from "@/features/git"; +import { ConversationViewer } from "@/features/conversations"; +import { + Button, + DockRegion, + FloatingWindow, + IconButton, + MenuBar, + Panel, + Spinner, + cn, + type FloatingWindowSize, + type MenuBarItem, + type MenuBarMenu, +} from "@/shared"; import { WebAgentCell } from "./WebAgentCell"; import { useLiveReconnect } from "./useLiveReconnect"; import { useLiveConnectionState } from "./useLiveConnectionState"; @@ -48,26 +70,10 @@ import { WebSprintsView } from "./tickets/WebSprintsView"; import { WebProjectsPanel } from "./WebProjectsPanel"; import { WebMenuSheet } from "./WebMenuSheet"; import { - WEB_PANEL_LABEL, WEB_PANEL_META, - WEB_PANEL_ORDER, - isWebOpenablePanel, type WebOpenablePanelId, type WebProjectPanelId, } from "./webSurfaceCatalog"; -import { - openWebSurfaceInBrowser, - type WebSurfaceOpenTarget, -} from "./webSurfaceNavigation"; -import { - DEFAULT_WEB_WORKSPACE_PRESENTATION, - loadWebWorkspacePresentation, - saveWebWorkspacePresentation, - withOpenedSurface, - type WebSurfacePlacement, - type WebSurfacePlacements, -} from "./webWorkspaceSession"; -import { createWebWorkspaceSyncBus, type WebWorkspaceSyncProject } from "./webWorkspaceSync"; function describe(e: unknown): string { if (e && typeof e === "object" && "message" in e) { @@ -82,12 +88,6 @@ export function WebWorkspace() { const [error, setError] = useState(null); const [openId, setOpenId] = useState(null); const [panel, setPanel] = useState("projects"); - const [openNotice, setOpenNotice] = useState(null); - const [primaryRailOpen, setPrimaryRailOpen] = useState(true); - const [secondaryRailOpen, setSecondaryRailOpen] = useState(true); - const [openedSurfaces, setOpenedSurfaces] = useState({}); - const syncBus = useMemo(() => createWebWorkspaceSyncBus(), []); - const projectsRef = useRef(null); // Set by the Sprints panel's "Voir tickets"; consumed once by the Tickets panel. const [focusSprintId, setFocusSprintId] = useState(null); const mobile = useMobileWorkspace(); @@ -97,33 +97,6 @@ export function WebWorkspace() { () => projects?.find((p) => p.id === openId) ?? null, [openId, projects], ); - projectsRef.current = projects; - - const loadPresentationForProject = useCallback((projectId: string) => { - const saved = loadWebWorkspacePresentation(projectId); - setPanel(saved.activePanel); - setPrimaryRailOpen(saved.primaryRailOpen); - setSecondaryRailOpen(saved.secondaryRailOpen); - setOpenedSurfaces(saved.openedSurfaces); - return saved; - }, []); - - const syncProject = useCallback((p: Project): WebWorkspaceSyncProject => ({ - id: p.id, - name: p.name, - root: p.root, - }), []); - - const publishFocus = useCallback( - (p: Project, activePanel: WebProjectPanelId) => { - syncBus.publish({ - type: "focus", - project: syncProject(p), - activePanel, - }); - }, - [syncBus, syncProject], - ); const refresh = useCallback(async () => { setError(null); @@ -138,45 +111,11 @@ export function WebWorkspace() { void refresh(); }, [refresh]); - useEffect(() => () => syncBus.close(), [syncBus]); - - useEffect(() => { - return syncBus.subscribe((event) => { - if (event.type === "focus") { - const knownProject = projectsRef.current?.find((p) => p.id === event.project.id); - if (!knownProject) return; - void project.openProject(knownProject.id).then(() => { - setOpenId(knownProject.id); - loadPresentationForProject(knownProject.id); - setPanel(event.activePanel); - }).catch(() => {}); - return; - } - const sameProjectOpen = openId === event.project.id; - if (event.type === "surface-opened" && sameProjectOpen) { - setOpenedSurfaces((cur) => withOpenedSurface(cur, event.panel, event.placement)); - } - }); - }, [loadPresentationForProject, openId, project, syncBus]); - - useEffect(() => { - if (!openProject) return; - saveWebWorkspacePresentation(openProject.id, { - version: DEFAULT_WEB_WORKSPACE_PRESENTATION.version, - activePanel: panel === "projects" ? DEFAULT_WEB_WORKSPACE_PRESENTATION.activePanel : panel, - primaryRailOpen, - secondaryRailOpen, - openedSurfaces, - }); - }, [openProject, openedSurfaces, panel, primaryRailOpen, secondaryRailOpen]); - const selectPanel = useCallback( (nextPanel: WebProjectPanelId) => { setPanel(nextPanel); - setOpenNotice(null); - if (openProject) publishFocus(openProject, nextPanel); }, - [openProject, publishFocus], + [], ); const openReadOnly = useCallback( @@ -187,15 +126,13 @@ export function WebWorkspace() { // Read-only: resolve the project server-side; the live panel then streams // its work-state. No layout/PTY is mounted here. await project.openProject(projectId); - const saved = loadPresentationForProject(projectId); setOpenId(projectId); - const nextProject = projectsRef.current?.find((p) => p.id === projectId); - if (nextProject) publishFocus(nextProject, saved.activePanel); + setPanel("work"); } catch (e) { setError(describe(e)); } }, - [loadPresentationForProject, project, publishFocus], + [project], ); const createProject = useCallback( @@ -213,39 +150,6 @@ export function WebWorkspace() { [project, refresh], ); - const openSurface = useCallback( - (target: WebSurfaceOpenTarget) => { - setOpenNotice(null); - if (!openProject || !isWebOpenablePanel(panel)) return; - const outcome = openWebSurfaceInBrowser( - { - panel, - projectId: openProject.id, - projectRoot: openProject.root, - projectName: openProject.name, - focusSprintId: panel === "tickets" ? focusSprintId : null, - }, - target, - ); - if (outcome.result === "tab" || outcome.result === "window") { - const placement: WebSurfacePlacement = outcome.result; - setOpenedSurfaces((cur) => withOpenedSurface(cur, panel, placement)); - syncBus.publish({ - type: "surface-opened", - project: syncProject(openProject), - panel, - placement, - }); - } - if (target === "window" && outcome.result === "tab") { - setOpenNotice("Fenêtre bloquée — ouverture en onglet."); - } else if (outcome.result === "blocked") { - setOpenNotice("Le navigateur a bloqué l'ouverture de la surface."); - } - }, - [focusSprintId, openProject, panel, syncBus, syncProject], - ); - if (mobile) { return ( -
- setPrimaryRailOpen((open) => !open)} - onToggleSecondaryRail={() => setSecondaryRailOpen((open) => !open)} - onShowProjects={() => selectPanel("projects")} - onOpenHere={() => setOpenNotice(null)} - onOpenTab={() => openSurface("tab")} - onOpenWindow={() => openSurface("window")} - /> - -
- - -
-
- -
- -
- {openNotice && ( -
- {openNotice} -
- )} - {error && } - void openReadOnly(id)} - onCreateProject={createProject} - onRefreshProjects={() => void refresh()} - onViewSprintTickets={(sprintId) => { - setFocusSprintId(sprintId); - selectPanel("tickets"); - }} - /> -
-
- - void openReadOnly(id)} - onRefreshProjects={() => void refresh()} - onSelectPanel={selectPanel} - onOpenSurfaceHere={selectPanel} - /> -
-
- + void openReadOnly(id)} + onCreateProject={createProject} + onRefreshProjects={() => void refresh()} + onViewSprintTickets={(sprintId) => { + setFocusSprintId(sprintId); + selectPanel("tickets"); + }} + /> ); } @@ -375,6 +205,379 @@ function useMobileWorkspace(): boolean { return mobile; } +const WEB_PANEL_SIZE: Record = { + projects: "md", + context: "lg", + work: "lg", + tickets: "lg", + agents: "lg", + templates: "lg", + skills: "lg", + permissions: "md", + memory: "lg", + git: "lg", +}; + +function WebDesktopWorkspace({ + projects, + openId, + openProject, + openRoot, + error, + focusSprintId, + onSelectPanel, + onOpenProject, + onCreateProject, + onRefreshProjects, + onViewSprintTickets, +}: { + projects: Project[] | null; + openId: string | null; + openProject: Project | null; + openRoot: string | null; + error: string | null; + focusSprintId: string | null; + onSelectPanel: (panel: WebProjectPanelId) => void; + onOpenProject: (projectId: string) => void; + onCreateProject: (name: string, root: string) => Promise; + onRefreshProjects: () => void; + onViewSprintTickets: (sprintId: string) => void; +}) { + const [placements, setPlacements] = useState({ + work: { dock: "right" }, + }); + const [leftDockWidth, setLeftDockWidth] = useState(340); + const [rightDockWidth, setRightDockWidth] = useState(340); + const [activeLayout, setActiveLayout] = useState(null); + const [viewerConversation, setViewerConversation] = useState<{ + projectId: string; + conversationId: string; + } | null>(null); + + useEffect(() => { + setActiveLayout(null); + setViewerConversation(null); + }, [openId]); + + function setPlacement(panel: PanelId, placement: Exclude) { + setPlacements((prev) => { + const next: ViewPlacements = { ...prev }; + if (placement === "floating") { + for (const other of Object.keys(next) as PanelId[]) { + if (other !== panel && next[other] === "floating") delete next[other]; + } + } + next[panel] = placement; + return next; + }); + } + + function closePanel(panel: PanelId) { + setPlacements((prev) => { + if (!(panel in prev)) return prev; + const next = { ...prev }; + delete next[panel]; + return next; + }); + } + + function dismissFloating() { + setPlacements((prev) => { + let changed = false; + const next = { ...prev }; + for (const panel of Object.keys(next) as PanelId[]) { + if (next[panel] === "floating") { + delete next[panel]; + changed = true; + } + } + return changed ? next : prev; + }); + } + + function openConversation(conversationId: string) { + if (!openProject) return; + setViewerConversation({ projectId: openProject.id, conversationId }); + dismissFloating(); + } + + const panelOrder: PanelId[] = [ + "context", + "work", + "tickets", + "agents", + "templates", + "skills", + "permissions", + "memory", + "git", + "projects", + ]; + const panelToWebId = (panel: PanelId): WebProjectPanelId => panel; + + function placementSubmenu(panel: PanelId): MenuBarItem[] { + const placement = placementOf(placements, panel); + return [ + { + id: "closed", + label: "Fermé", + active: placement === "closed", + onSelect: () => closePanel(panel), + }, + { + id: "floating", + label: "Flottant", + active: placement === "floating", + onSelect: () => setPlacement(panel, "floating"), + }, + { + id: "dock-left", + label: "Ancré à gauche", + active: isDockedTo(placement, "left"), + onSelect: () => setPlacement(panel, { dock: "left" }), + }, + { + id: "dock-right", + label: "Ancré à droite", + active: isDockedTo(placement, "right"), + onSelect: () => setPlacement(panel, { dock: "right" }), + }, + ]; + } + + const menus: MenuBarMenu[] = [ + { + id: "panels", + label: "Panneaux", + items: panelOrder.map((panel) => ({ + id: panel, + label: PANEL_TITLE[panel], + active: placementOf(placements, panel) !== "closed", + onSelect: () => {}, + submenu: placementSubmenu(panel), + })), + }, + ]; + + const projectsManager = ( + + ); + + function renderPanel(panel: PanelId) { + if (panel === "projects") return projectsManager; + if (!openId) { + return

Ouvrir un projet pour utiliser ce panneau.

; + } + return ( + + ); + } + + function DockControls({ panel }: { panel: PanelId }) { + const placement = placementOf(placements, panel); + const title = PANEL_TITLE[panel]; + const activeClass = "bg-raised text-content ring-1 ring-border-strong"; + return ( +
+ setPlacement(panel, { dock: "left" })} + > + ⇤ + + setPlacement(panel, { dock: "right" })} + > + ⇥ + + setPlacement(panel, "floating")} + > + □ + + closePanel(panel)} + > + × + +
+ ); + } + + function renderDockedView(panel: PanelId) { + return ( +
+
+ + {PANEL_TITLE[panel]} + + +
+
{renderPanel(panel)}
+
+ ); + } + + const leftPanels = panelsDockedTo(placements, "left"); + const rightPanels = panelsDockedTo(placements, "right"); + const floatingList = floatingPanels(placements); + const activeLayoutKind = activeLayout?.kind ?? "terminal"; + const showInlineProjects = + !openProject && placementOf(placements, "projects") === "closed"; + + return ( +
+
+
+

+ {openProject ? openProject.name : "Aucun projet ouvert"} +

+

+ {openProject?.root ?? "Ouvrir un projet pour accéder aux layouts."} +

+
+ +
+ + + {error && ( +
+ +
+ )} + +
+ {leftPanels.length > 0 && ( + + {leftPanels.map(renderDockedView)} + + )} + +
+ {openProject ? ( + <> + + {viewerConversation ? ( + setViewerConversation(null)} + /> + ) : activeLayoutKind === "gitGraph" ? ( + + ) : ( + + )} + + ) : ( +
+
+ {showInlineProjects ? ( + projectsManager + ) : ( +

+ Sélectionner ou créer un projet pour démarrer. +

+ )} +
+
+ )} +
+ + {rightPanels.length > 0 && ( + + {rightPanels.map(renderDockedView)} + + )} +
+ + {floatingList.map((panel) => ( + closePanel(panel)} + > +
+ +
+ {renderPanel(panel)} +
+ ))} +
+ ); +} + function WebMobileTaskFlowShell({ panel, projects, @@ -603,54 +806,6 @@ function WebMobileTaskSurface({ ); } -function WebWorkspaceSurface({ - panel, - projects, - openId, - openRoot, - focusSprintId, - onOpenProject, - onCreateProject, - onRefreshProjects, - onViewSprintTickets, -}: { - panel: WebProjectPanelId; - projects: Project[] | null; - openId: string | null; - openRoot: string | null; - focusSprintId: string | null; - onOpenProject: (projectId: string) => void; - onCreateProject: (name: string, root: string) => Promise; - onRefreshProjects: () => void; - onViewSprintTickets: (sprintId: string) => void; -}) { - if (panel === "projects") { - return ( - - ); - } - - if (!openId) { - return

Ouvrir un projet pour utiliser ce panneau.

; - } - - return ( - - ); -} - function WebWorkspaceError({ message }: { message: string }) { return ( @@ -659,369 +814,6 @@ function WebWorkspaceError({ message }: { message: string }) { ); } -function WebDesktopTopBar({ - activePanel, - openProject, - projectCount, - primaryRailOpen, - secondaryRailOpen, - onTogglePrimaryRail, - onToggleSecondaryRail, - onShowProjects, - onOpenHere, - onOpenTab, - onOpenWindow, -}: { - activePanel: WebProjectPanelId; - openProject: Project | null; - projectCount: number | null; - primaryRailOpen: boolean; - secondaryRailOpen: boolean; - onTogglePrimaryRail: () => void; - onToggleSecondaryRail: () => void; - onShowProjects: () => void; - onOpenHere: () => void; - onOpenTab: () => void; - onOpenWindow: () => void; -}) { - const canOpenSurface = openProject !== null && isWebOpenablePanel(activePanel); - return ( -
-
- - Surface {WEB_PANEL_META[activePanel].label} - - - {openProject ? openProject.name : "Aucun projet ouvert"} - -
-
- - - - - {projectCount === null ? "Projets…" : `${projectCount} projet${projectCount > 1 ? "s" : ""}`} - - -
-
- ); -} - -function WebSurfaceOpenControls({ - disabled, - onOpenHere, - onOpenTab, - onOpenWindow, -}: { - disabled: boolean; - onOpenHere: () => void; - onOpenTab: () => void; - onOpenWindow: () => void; -}) { - return ( -
- - - -
- ); -} - -function WebPrimaryRail({ - activePanel, - hasProject, - open, - onSelect, -}: { - activePanel: WebProjectPanelId; - hasProject: boolean; - open: boolean; - onSelect: (panel: WebProjectPanelId) => void; -}) { - return ( - - ); -} - -function WebSecondaryRail({ - projects, - openId, - openProject, - activePanel, - open, - openedSurfaces, - onOpenProject, - onRefreshProjects, - onSelectPanel, - onOpenSurfaceHere, -}: { - projects: Project[] | null; - openId: string | null; - openProject: Project | null; - activePanel: WebProjectPanelId; - open: boolean; - openedSurfaces: WebSurfacePlacements; - onOpenProject: (projectId: string) => void; - onRefreshProjects: () => void; - onSelectPanel: (panel: WebProjectPanelId) => void; - onOpenSurfaceHere: (panel: WebProjectPanelId) => void; -}) { - const secondaryPanels = WEB_PANEL_ORDER.filter( - (id) => WEB_PANEL_META[id].placement === "secondary", - ); - const openedSurfaceEntries = Object.entries(openedSurfaces) as Array< - [WebOpenablePanelId, WebSurfacePlacement] - >; - - return ( - - ); -} - -/** Compact trigger + full-screen sheet for the "Panneaux" menu (#90). */ -function WebPanelMenuButton({ - activePanel, - hasProject, - onSelect, -}: { - activePanel: WebProjectPanelId; - hasProject: boolean; - onSelect: (panel: WebProjectPanelId) => void; -}) { - const [open, setOpen] = useState(false); - return ( -
- - {open && ( - setOpen(false)} - entries={WEB_PANEL_ORDER.map((id) => { - const disabled = id !== "projects" && !hasProject; - return { - id, - label: WEB_PANEL_LABEL[id], - active: id === activePanel, - disabled, - hint: disabled ? "Ouvrir un projet pour utiliser ce panneau." : undefined, - onSelect: () => { - onSelect(id); - setOpen(false); - }, - }; - })} - /> - )} -
- ); -} - /** Renders the requested panel's body for the opened project. */ export function WebProjectPanelBody({ panel,