From 5cd3a4827164843af14513d0943a2799e9501d98 Mon Sep 17 00:00:00 2001 From: jaylfc Date: Mon, 15 Jun 2026 15:57:02 +0100 Subject: [PATCH 01/40] docs(status): release #938 to master; DeepSeek + popularity merged; Pi disk freed --- docs/STATUS.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/docs/STATUS.md b/docs/STATUS.md index 4559eaad..3f9f7d6e 100644 --- a/docs/STATUS.md +++ b/docs/STATUS.md @@ -1,5 +1,7 @@ SINGLE SOURCE OF TRUTH for cross-agent handoff. -Last updated: 2026-06-15 ~12:23 BST, @taOS (ACTIVE). +Last updated: 2026-06-15 ~15:52 BST, @taOS (ACTIVE). + +▶▶ RELEASED TO MASTER 2026-06-15 (#938, master=cfb04499, Jay: "if everything is good and merged to dev merge to master"): dev->master clean fast-forward of 124 commits since #889. Ships Image Studio (#42/#43) + tier-aware edit backends (IOPaint/Real-ESRGAN/FLUX Fill), Game Studio phase 1 (#935), DeepSeek provider (#937), store popularity backend (#936/#13), app redesigns (GitHub/Reddit/Messages/Agents/Contacts/Weather/MediaPlayer), A2A bus in Messages (#910), Files context menu (#911), Indigo theme (#927), mobile/PWA safe-area fixes (#913), secrets-inject-at-deploy (#919), catalog install scripts + port hygiene, reproducible deps lockfile. dev CI green (lint+spa-build+tests 3.12/3.13); merge-commit preserves history; admin override (master strict-mode + behind). THIS SESSION before release: #937 DeepSeek MERGED (verified v4 ids vs DeepSeek docs; usable by the Pi's live Hermes agent once a user adds DEEPSEEK_API_KEY -- Jay confirmed it was a user-requested capability, no key on our side); #936 popularity MERGED with an atomic-cache-write fix (#13 DONE); README refreshed (Game/Image Studio + 39 apps + DeepSeek). DISK: agent backups (13G) MOVED to Mac NVMe (~/pi-archive/agent-backups, byte+gzip verified) then removed from Pi; docker prune (~18G); Pi now 75G free / 84% (was 45G/91%) -- IOPaint fast-tier deploy now unblocked on space (awaiting test). NEXT: backlog #15 install/download telemetry (website /data SQLite on taos-site-data volume, phase-1 counting), #47 mobile spot-checks; IOPaint test. HOLD for Jay/spec: design-heavy #14/#16/#17/#18/#19, #49 Hermes full migration (a Hermes gateway already RUNS on the Pi; #49 is the Mac-instance relocation), all Game Studio next phases #50/#52/#53/#54/#55/#56, #51 eval. ▶▶ CURRENT STATE 2026-06-15 ~14:18 BST: - GAME STUDIO + BACKLOG (Jay challenge + keep-pushing): dev=9aa235ff, Pi redeployed. SIX PRs merged under the tightened gitar gate (every ⚠️ Bug/Security/Edge-Case fixed before merge, real findings caught on my own fix PRs): #930 (theme wallpaper-clobber on reload), #931 (image-edit reliability: mask strip, non-image-200 guard, degraded flag scoped to quality-downgrade, _require_image accepts image-magic, save errors, copy-into-self), #932 (image-edit backends AUTO-REGISTER via lifecycle -- edit tier was 503-on-install; + ltx-video), #933 (flux-fill loopback bind, Jay directive), #934 (safety-regression test: SafetyFloor aria-label), #935 (GAME STUDIO app shell phase 1: real three.js WebGLRenderer preview, fullscreen with mandatory Exit-to-taOS + Esc, device preview, mobile; AI-gen/store-publish/XR/skill-pack honestly stubbed for later phases; key-stick + global-capture fixed). DEPLOY NOTE: Pi redeploy MUST run npm install before build when deps change (#935 added three; first rebuild failed without it). DEEPER AUDIT (Jay) earlier found + fixed the edit-tier reliability + auto-register gaps. TOOLS RESEARCH (Jay, 2026): FLUX.1-Fill is NON-COMMERCIAL -> #54 keep as optional arms-length non-commercial store install (like #30), default to Apache-clean Z-Image Turbo (gen) + Qwen-Image-Edit (edit); #55 offline gen stack = Hunyuan3D 2.1+PolyGen+Paint (3D) + MOSS-SoundEffect (SFX) + ACE-Step 1.5 (music); #51 agent = Qwen3.5-9B (Gemma 4 26B overflows 12GB, E4B unproven); three.js confirmed framework. NEW TASKS #50-#56 (Game Studio app/scoreboard/taOS-apps-category/license-stack/gen-stack/permissions). GATE TIGHTENED (handoff 0f, local): merge on FINDING SEVERITY not review state. From 5c513bfa689779759f2aca5eeb0d30ac3ef454d5 Mon Sep 17 00:00:00 2001 From: jaylfc Date: Mon, 15 Jun 2026 16:32:37 +0100 Subject: [PATCH 02/40] feat(notifications): wire the desktop bell to the backend notification feed (#939) * feat(notifications): wire desktop bell to backend notification feed The desktop notification bell read only from a client-only zustand store, so the persistent backend feed (worker join/leave, backend up/down, training, app install, disk quota) never reached the UI. Add server-notifications lib (fetch/map/mark-read against /api/notifications), a mergeServerNotifications store action that upserts server rows while preserving local read state and keeping client items, a polling hook (30s + on-open refresh) mounted in App for both shells, and wire mark-read / mark-all-read to also persist on the backend. Server rows are mapped to the frontend shape with an srv- id prefix and seconds-to-millis timestamps; unknown levels fall back to info. Fetch failures yield an empty list and never throw. * fix(notifications): keep dismissed server items dismissed; pause poll when tab hidden Address gitar review on #939: - dismiss()/clearAll() record server ids so the next poll does not resurrect them (the merge filters dismissedServerIds). - pause the 30s poll while the tab is backgrounded; resync on return. --- desktop/src/App.tsx | 5 + desktop/src/components/NotificationCentre.tsx | 17 ++- desktop/src/hooks/use-server-notifications.ts | 66 ++++++++++ desktop/src/lib/server-notifications.test.ts | 123 ++++++++++++++++++ desktop/src/lib/server-notifications.ts | 91 +++++++++++++ desktop/src/stores/notification-store.test.ts | 89 +++++++++++++ desktop/src/stores/notification-store.ts | 35 ++++- 7 files changed, 423 insertions(+), 3 deletions(-) create mode 100644 desktop/src/hooks/use-server-notifications.ts create mode 100644 desktop/src/lib/server-notifications.test.ts create mode 100644 desktop/src/lib/server-notifications.ts create mode 100644 desktop/src/stores/notification-store.test.ts diff --git a/desktop/src/App.tsx b/desktop/src/App.tsx index e5c1be0b..b4805a80 100644 --- a/desktop/src/App.tsx +++ b/desktop/src/App.tsx @@ -24,6 +24,7 @@ import { LoginScreen } from "@/components/LoginScreen"; import { NotificationToasts } from "@/components/NotificationToast"; import { NotificationCentre } from "@/components/NotificationCentre"; import { useNotificationStore } from "@/stores/notification-store"; +import { useServerNotifications } from "@/hooks/use-server-notifications"; import { TaosAssistantPanel } from "@/components/TaosAssistantPanel"; import { useTaosAgentStore } from "@/stores/taos-agent-store"; import { InstallPromptBanner } from "@/shell/InstallPromptBanner"; @@ -189,6 +190,10 @@ export function App() { useSessionPersistence(); + // Sync the persistent backend notification feed into the bell (desktop and + // mobile both render NotificationCentre under this component). + useServerNotifications(); + // Re-apply the persisted active theme on app boot so a reload keeps the // user's chosen theme app-wide (not only when Settings is opened). useEffect(() => { diff --git a/desktop/src/components/NotificationCentre.tsx b/desktop/src/components/NotificationCentre.tsx index c51350b1..544a3f31 100644 --- a/desktop/src/components/NotificationCentre.tsx +++ b/desktop/src/components/NotificationCentre.tsx @@ -1,6 +1,7 @@ import { useState } from "react"; import { X, Bell, CheckCheck, Trash2 } from "lucide-react"; import { useNotificationStore } from "@/stores/notification-store"; +import { markServerRead, markAllServerRead } from "@/lib/server-notifications"; import { SetupChecklist } from "./SetupChecklist"; function formatTime(ts: number): string { @@ -15,6 +16,18 @@ export function NotificationCentre() { const { notifications, centreOpen, closeCentre, markRead, markAllRead, clearAll, dismiss } = useNotificationStore(); const [checklistDismissed, setChecklistDismissed] = useState(false); + // Optimistic local mark-read, plus a best-effort backend write for server + // items so the read state persists across reloads. Network never blocks the UI. + const handleMarkRead = (id: string) => { + markRead(id); + void markServerRead(id); + }; + + const handleMarkAllRead = () => { + markAllRead(); + void markAllServerRead(); + }; + if (!centreOpen) return null; const isMobile = typeof window !== "undefined" && window.innerWidth < 640; @@ -56,7 +69,7 @@ export function NotificationCentre() {
{notifications.length > 0 && ( <> - -
    +
      {projects.length === 0 ? ( -
    • -

      No projects yet

      -

      Organise your work and agent conversations into projects.

      +
    • +

      No projects yet

      +

      + Organise your work and agent conversations into projects. +

      @@ -44,12 +57,13 @@ export function ProjectList({ projects, selectedId, onSelect, onCreated }: Props type="button" aria-pressed={p.id === selectedId} onClick={() => onSelect(p.id)} - className={`w-full text-left px-3 py-2 hover:bg-zinc-800 ${ - p.id === selectedId ? "bg-zinc-800" : "" - }`} + className={`${styles.pj} ${p.id === selectedId ? styles.pjOn : ""}`} > -
      {p.name}
      -
      {p.slug}
      + {mark(p.name)} + + {p.name} + {p.slug} +
    • )) @@ -64,6 +78,6 @@ export function ProjectList({ projects, selectedId, onSelect, onCreated }: Props }} /> )} - + ); } diff --git a/desktop/src/apps/ProjectsApp/ProjectWorkspace.tsx b/desktop/src/apps/ProjectsApp/ProjectWorkspace.tsx index db2d2e36..ecb4118e 100644 --- a/desktop/src/apps/ProjectsApp/ProjectWorkspace.tsx +++ b/desktop/src/apps/ProjectsApp/ProjectWorkspace.tsx @@ -1,5 +1,5 @@ -import { useEffect, useState } from "react"; -import { projectsApi, type Project } from "@/lib/projects"; +import { useEffect, useMemo, useState } from "react"; +import { projectsApi, type Project, type ProjectMember } from "@/lib/projects"; import { ProjectTaskList } from "./ProjectTaskList"; import { ProjectMembers } from "./ProjectMembers"; import { ProjectActivity } from "./ProjectActivity"; @@ -8,13 +8,22 @@ import { TaskModal } from "./board/TaskModal"; import { FilesApp } from "@/apps/FilesApp"; import { MessagesApp } from "@/apps/MessagesApp"; import { CanvasView } from "./canvas/CanvasView"; +import { ProjectWorkspacePane } from "./ProjectWorkspacePane"; +import { derivePresence } from "./presence"; import { useIsMobile } from "../../hooks/use-is-mobile"; import { WorkspaceTabPills } from "../../components/mobile/WorkspaceTabPills"; import { ProjectFab } from "./mobile/ProjectFab"; import { TaskCreateSheet } from "./mobile/TaskCreateSheet"; +import styles from "./ProjectsApp.module.css"; -type Tab = "board" | "canvas" | "tasks" | "files" | "messages" | "members" | "activity"; -const TABS: Tab[] = ["board", "canvas", "tasks", "files", "messages", "members", "activity"]; +type Tab = "workspace" | "board" | "canvas" | "tasks" | "files" | "messages" | "members" | "activity"; +const TABS: Tab[] = ["workspace", "board", "canvas", "tasks", "files", "messages", "members", "activity"]; + +interface AgentSummary { + id: string; + name: string; + display_name?: string; +} function readTaskParam(): string | null { if (typeof window === "undefined") return null; @@ -31,11 +40,13 @@ function setTaskParam(taskId: string | null) { export function ProjectWorkspace({ project, onChanged }: { project: Project; onChanged: () => void }) { const isMobile = useIsMobile(); - const [tab, setTab] = useState("tasks"); + const [tab, setTab] = useState("workspace"); const [currentUserId, setCurrentUserId] = useState(null); const [authResolved, setAuthResolved] = useState(false); const [openTaskId, setOpenTaskId] = useState(() => readTaskParam()); const [createSheetOpen, setCreateSheetOpen] = useState(false); + const [members, setMembers] = useState([]); + const [agents, setAgents] = useState([]); const handleCreateTask = async ({ title }: { title: string }) => { await projectsApi.tasks.create(project.id, { title }); @@ -58,61 +69,114 @@ export function ProjectWorkspace({ project, onChanged }: { project: Project; onC return () => { cancelled = true; }; }, []); + // Members + agent roster drive the header presence row (static-but-real: + // derived from the existing member data, not live multiplayer presence). + useEffect(() => { + let cancelled = false; + projectsApi.members + .list(project.id) + .then((rows) => { if (!cancelled) setMembers(Array.isArray(rows) ? rows : []); }) + .catch(() => { if (!cancelled) setMembers([]); }); + return () => { cancelled = true; }; + }, [project.id]); + + useEffect(() => { + let cancelled = false; + fetch("/api/agents") + .then((r) => (r.ok ? r.json() : [])) + .then((rows) => { if (!cancelled && Array.isArray(rows)) setAgents(rows); }) + .catch(() => {}); + return () => { cancelled = true; }; + }, []); + useEffect(() => { const onPop = () => setOpenTaskId(readTaskParam()); window.addEventListener("popstate", onPop); return () => window.removeEventListener("popstate", onPop); }, []); + const agentName = useMemo(() => { + const byId = new Map(); + for (const a of agents) byId.set(a.id, a); + return (id: string) => { + const a = byId.get(id); + return a ? a.display_name || a.name : id; + }; + }, [agents]); + + const presence = useMemo( + () => derivePresence({ ownerInitial: "Y", members, agentName }), + [members, agentName], + ); + const openTask = (id: string) => { setTaskParam(id); setOpenTaskId(id); }; const closeTask = () => { setTaskParam(null); setOpenTaskId(null); }; return ( -
      -
      -
      -

      {project.name}

      - {project.description && ( -

      {project.description}

      +
      +
      +
      +

      {project.name}

      + {!isMobile && presence.length > 0 && ( +
      +
      + {presence.map((f) => ( + + {f.initial} + + + ))} +
      + + {presence.length} {presence.length === 1 ? "here" : "here now"} + +
      )}
      + {project.description && ( +

      {project.description}

      + )} + {isMobile ? ( + setTab(id as Tab)} + /> + ) : ( + + )}
      - {isMobile ? ( - setTab(id as Tab)} - /> - ) : ( - - )} +
      + {tab === "workspace" && } {tab === "board" && ( <> {!authResolved ? ( -
      Loading board…
      +
      Loading board…
      ) : currentUserId ? ( ) : ( -
      Sign in required to view the board.
      +
      Sign in required to view the board.
      )} {currentUserId && ( } {tab === "activity" && }
      + {isMobile && (tab === "tasks" || tab === "board") && ( <> setCreateSheetOpen(true)} /> diff --git a/desktop/src/apps/ProjectsApp/ProjectWorkspacePane.tsx b/desktop/src/apps/ProjectsApp/ProjectWorkspacePane.tsx new file mode 100644 index 00000000..de2e465f --- /dev/null +++ b/desktop/src/apps/ProjectsApp/ProjectWorkspacePane.tsx @@ -0,0 +1,129 @@ +import { useState } from "react"; +import type { Project } from "@/lib/projects"; +import { MessagesApp } from "@/apps/MessagesApp"; +import { CanvasView } from "./canvas/CanvasView"; +import styles from "./ProjectsApp.module.css"; + +type PreviewMode = "preview" | "code" | "canvas"; + +/** + * The Workspace tab (task #59 hero): a split pane. + * + * LEFT: the project channel thread + composer. Reuses the existing + * project-scoped MessagesApp (humans + agents, send logic, A2A bus), + * so this is real data, not a mock. + * RIGHT: a live-preview pane with a Preview | Code | Canvas segmented + * toggle and a small toolbar. + * + * Phase 1 scope: the Canvas toggle embeds the real project canvas. Preview and + * Code render honest placeholders rather than faking a running app build. A + * true streamed live build preview is #59 phase 2/3 (see TODO below). + */ +export function ProjectWorkspacePane({ project }: { project: Project }) { + const [mode, setMode] = useState("preview"); + + return ( +
      + {/* LEFT: real project channel thread + composer */} +
      +
      + +
      +
      + + {/* resize affordance (fixed sensible ratio in phase 1) */} +
      + + {/* RIGHT: preview / code / canvas */} +
      +
      +
      + {(["preview", "code", "canvas"] as PreviewMode[]).map((m) => ( + + ))} +
      +
      + + Live +
      +
      + {/* These toolbar actions are intentionally inert in phase 1: refresh, + device-size and open-in-window all hang off a real streamed build + preview, which is deferred. They are shown so the chrome matches + the approved mock without faking behavior. */} + + + +
      +
      + + {mode === "canvas" ? ( +
      + +
      + ) : mode === "code" ? ( +
      + +
      + ) : ( +
      + {/* TODO(#59 phase 2/3): replace with a real streamed live build + preview (iframe / StreamedBrowser to the running app). Until the + project build pipeline is wired, show an honest placeholder + rather than a hardcoded fake running app. */} + +
      + )} +
      +
      + ); +} + +function PreviewPlaceholder({ title, body }: { title: string; body: string }) { + return ( +
      +
      + + + + +
      +

      {title}

      +

      {body}

      + Phase 2 +
      + ); +} diff --git a/desktop/src/apps/ProjectsApp/ProjectsApp.module.css b/desktop/src/apps/ProjectsApp/ProjectsApp.module.css new file mode 100644 index 00000000..5cec40fb --- /dev/null +++ b/desktop/src/apps/ProjectsApp/ProjectsApp.module.css @@ -0,0 +1,530 @@ +/* Projects app redesign (task #59). + Tokens come from theme/tokens.css so dark and light both work; no hardcoded + theme colors here beyond the board status hues, which mirror the existing + board tokens. */ + +/* ---- left sidebar: project list ---- */ +.sidebar { + width: 248px; + flex: none; + display: flex; + flex-direction: column; + background: var(--color-shell-bg-deep); + border-right: 1px solid var(--color-shell-border); + height: 100%; + min-height: 0; +} +.sbHead { + height: 52px; + flex: none; + display: flex; + align-items: center; + justify-content: space-between; + padding: 0 16px; + border-bottom: 1px solid var(--color-shell-border); +} +.sbHead h2 { + font-size: 14px; + font-weight: 700; + letter-spacing: -0.01em; + color: var(--color-shell-text); +} +.newBtn { + display: flex; + align-items: center; + gap: 5px; + font-size: 12px; + font-weight: 600; + color: var(--color-shell-text-secondary); + background: var(--color-shell-surface); + border: 1px solid var(--color-shell-border); + border-radius: 999px; + padding: 5px 11px; + cursor: pointer; + transition: all 0.15s; +} +.newBtn:hover { + background: var(--color-shell-surface-hover); + color: var(--color-shell-text); + border-color: var(--color-shell-border-strong); +} +.newBtn svg { + width: 13px; + height: 13px; +} +.sbList { + flex: 1; + overflow: auto; + padding: 8px; + list-style: none; + margin: 0; +} +.pj { + display: flex; + gap: 11px; + align-items: center; + width: 100%; + text-align: left; + padding: 9px 10px; + border: none; + background: transparent; + border-radius: 13px; + cursor: pointer; + transition: all 0.15s; + margin-bottom: 2px; + color: var(--color-shell-text); +} +.pj:hover { + background: var(--color-shell-surface-hover); +} +.pjOn { + background: var(--color-shell-surface-active); +} +.pjOn .pjMark { + box-shadow: 0 0 0 2px var(--color-accent-glow); +} +.pjMark { + width: 32px; + height: 32px; + border-radius: 9px; + flex: none; + display: flex; + align-items: center; + justify-content: center; + font-size: 12px; + font-weight: 700; + color: #15161a; + background: linear-gradient(135deg, #7c8ba1, #aab4c9); +} +.pjBody { + min-width: 0; + flex: 1; +} +.pjName { + font-size: 13px; + font-weight: 600; + letter-spacing: -0.01em; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} +.pjMeta { + font-size: 11px; + color: var(--color-shell-text-tertiary); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + margin-top: 1px; + display: flex; + align-items: center; + gap: 6px; +} +.pjDot { + width: 6px; + height: 6px; + border-radius: 50%; + background: var(--color-board-status-closed, #30d158); + flex: none; + box-shadow: 0 0 0 3px rgba(48, 209, 88, 0.15); +} +.sbEmpty { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + height: 100%; + padding: 32px 16px; + text-align: center; + gap: 12px; +} +.sbEmpty p { + margin: 0; +} +.sbEmptyTitle { + font-size: 13px; + font-weight: 600; + color: var(--color-shell-text); +} +.sbEmptySub { + font-size: 12px; + color: var(--color-shell-text-secondary); + line-height: 1.45; +} + +/* ---- main column header ---- */ +.header { + flex: none; + padding: 14px 22px 0; + border-bottom: 1px solid var(--color-shell-border); +} +.titleRow { + display: flex; + align-items: center; + gap: 12px; +} +.titleRow h1 { + font-size: 19px; + font-weight: 700; + letter-spacing: -0.02em; + color: var(--color-shell-text); + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.desc { + font-size: 12.5px; + color: var(--color-shell-text-secondary); + margin-top: 4px; + max-width: 680px; + line-height: 1.45; +} + +/* presence row */ +.presence { + margin-left: auto; + display: flex; + align-items: center; + gap: 10px; + flex: none; +} +.stack { + display: flex; +} +.av { + width: 28px; + height: 28px; + border-radius: 50%; + border: 2px solid var(--color-shell-bg); + margin-left: -8px; + display: flex; + align-items: center; + justify-content: center; + font-size: 11px; + font-weight: 700; + position: relative; +} +.av:first-child { + margin-left: 0; +} +.avHuman { + background: linear-gradient(135deg, #6f7d93, #9aa4b8); + color: #15161a; +} +.avAgent { + background: linear-gradient(135deg, #2c303b, #444a5b); + color: var(--color-shell-text); +} +.avRing { + position: absolute; + right: -1px; + bottom: -1px; + width: 9px; + height: 9px; + border-radius: 50%; + background: var(--color-board-status-closed, #30d158); + border: 2px solid var(--color-shell-bg); +} +.presenceLbl { + font-size: 11px; + color: var(--color-shell-text-tertiary); + font-weight: 600; +} + +/* tabs */ +.tabs { + display: flex; + gap: 2px; + padding: 10px 0 0; + margin-top: 12px; + overflow-x: auto; +} +.tab { + position: relative; + font-size: 13px; + font-weight: 600; + color: var(--color-shell-text-tertiary); + padding: 8px 13px 12px; + cursor: pointer; + white-space: nowrap; + transition: color 0.15s; + border: none; + background: none; + text-transform: capitalize; +} +.tab:hover { + color: var(--color-shell-text-secondary); +} +.tabOn { + color: var(--color-shell-text); +} +.tabOn::after { + content: ""; + position: absolute; + left: 11px; + right: 11px; + bottom: 0; + height: 2px; + border-radius: 2px; + background: var(--color-accent-strong); +} + +/* panel host */ +.panel { + flex: 1; + min-height: 0; + display: flex; + flex-direction: column; +} +.panelPad { + flex: 1; + min-height: 0; + overflow: auto; + padding: 16px 22px; +} + +/* ========================================================= + WORKSPACE (hero) split pane + ========================================================= */ +.ws { + flex: 1; + display: flex; + min-height: 0; +} +.wsLeft { + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; + min-height: 0; +} +.wsDivider { + width: 5px; + flex: none; + cursor: col-resize; + position: relative; + background: transparent; +} +.wsDivider::before { + content: ""; + position: absolute; + left: 2px; + top: 0; + bottom: 0; + width: 1px; + background: var(--color-shell-border); +} +.wsDivider::after { + content: ""; + position: absolute; + left: 1px; + top: 50%; + transform: translateY(-50%); + width: 3px; + height: 34px; + border-radius: 99px; + background: var(--color-shell-border-strong); +} +.wsRight { + flex: none; + display: flex; + flex-direction: column; + min-height: 0; + background: var(--color-shell-bg-deep); +} + +/* left: thread host (MessagesApp scoped to the project) */ +.wsThread { + flex: 1; + min-height: 0; + display: flex; + flex-direction: column; +} + +/* right: preview pane */ +.pvBar { + height: 50px; + flex: none; + display: flex; + align-items: center; + gap: 10px; + padding: 0 16px; + border-bottom: 1px solid var(--color-shell-border); +} +.seg { + display: flex; + background: var(--color-shell-surface); + border: 1px solid var(--color-shell-border); + border-radius: 999px; + padding: 3px; +} +.seg button { + font-size: 11px; + font-weight: 600; + color: var(--color-shell-text-secondary); + padding: 5px 13px; + border-radius: 999px; + cursor: pointer; + border: none; + background: none; + transition: all 0.15s; +} +.segOn { + background: var(--color-shell-surface-active); + color: var(--color-shell-text) !important; +} +.pvLive { + display: flex; + align-items: center; + gap: 6px; + font-size: 10.5px; + font-weight: 600; + color: var(--color-shell-text-secondary); +} +.pvPulse { + position: relative; + width: 7px; + height: 7px; +} +.pvPulse i { + position: absolute; + inset: 0; + border-radius: 50%; + background: var(--color-board-status-closed, #30d158); +} +.pvPulse::after { + content: ""; + position: absolute; + inset: 0; + border-radius: 50%; + background: var(--color-board-status-closed, #30d158); + animation: pvPulse 2.2s ease-out infinite; +} +@keyframes pvPulse { + 0% { transform: scale(1); opacity: 0.6; } + 100% { transform: scale(3); opacity: 0; } +} +.pvTools { + margin-left: auto; + display: flex; + gap: 5px; +} +.pvTool { + width: 30px; + height: 30px; + border-radius: 8px; + display: flex; + align-items: center; + justify-content: center; + color: var(--color-shell-text-tertiary); + cursor: pointer; + border: 1px solid var(--color-shell-border); + background: var(--color-shell-surface); + transition: all 0.15s; +} +.pvTool:hover { + background: var(--color-shell-surface-hover); + color: var(--color-shell-text-secondary); + border-color: var(--color-shell-border-strong); +} +.pvTool svg { + width: 15px; + height: 15px; +} +.pvStage { + flex: 1; + min-height: 0; + display: flex; + flex-direction: column; + overflow: auto; +} +.pvCanvas { + flex: 1; + min-height: 0; + display: flex; +} + +/* honest preview placeholder */ +.placeholder { + flex: 1; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 14px; + padding: 40px; + text-align: center; +} +.phIc { + width: 64px; + height: 64px; + border-radius: 20px; + display: flex; + align-items: center; + justify-content: center; + background: var(--color-shell-surface); + border: 1px solid var(--color-shell-border); +} +.phIc svg { + width: 28px; + height: 28px; + color: var(--color-accent-strong); +} +.placeholder h3 { + font-size: 16px; + font-weight: 700; + letter-spacing: -0.01em; + color: var(--color-shell-text); +} +.placeholder p { + font-size: 12.5px; + color: var(--color-shell-text-secondary); + max-width: 340px; + line-height: 1.5; +} +.tagb { + font-size: 10.5px; + font-weight: 600; + color: var(--color-accent-strong); + background: var(--color-accent-soft); + border: 1px solid var(--color-accent-line); + border-radius: 999px; + padding: 4px 12px; +} + +/* code view */ +.codeStage { + flex: 1; + min-height: 0; + overflow: auto; + padding: 16px; +} +.codeView { + font-family: "SF Mono", ui-monospace, Menlo, monospace; + font-size: 11.5px; + line-height: 1.65; + background: var(--color-shell-bg); + border: 1px solid var(--color-shell-border); + border-radius: 14px; + padding: 16px 18px; + color: var(--color-shell-text-secondary); + white-space: pre-wrap; + word-break: break-word; +} + +/* ========================================================= + Mobile: stack the split pane + ========================================================= */ +@media (max-width: 767px) { + .ws { + flex-direction: column; + } + .wsDivider { + display: none; + } + .wsRight { + width: 100% !important; + border-top: 1px solid var(--color-shell-border); + min-height: 280px; + } +} diff --git a/desktop/src/apps/ProjectsApp/__tests__/ProjectWorkspace.mobile.test.tsx b/desktop/src/apps/ProjectsApp/__tests__/ProjectWorkspace.mobile.test.tsx index 4ac5ad09..238fa73e 100644 --- a/desktop/src/apps/ProjectsApp/__tests__/ProjectWorkspace.mobile.test.tsx +++ b/desktop/src/apps/ProjectsApp/__tests__/ProjectWorkspace.mobile.test.tsx @@ -8,7 +8,7 @@ vi.mock("../../../hooks/use-is-mobile", () => ({ })); import { useIsMobile } from "../../../hooks/use-is-mobile"; -// Mock heavy children — we only care about the tab strip switch here. +// Mock heavy children: we only care about the tab strip switch here. vi.mock("../board/ProjectBoard", () => ({ ProjectBoard: () =>
      })); vi.mock("../board/TaskModal", () => ({ TaskModal: () =>
      })); vi.mock("../canvas/CanvasView", () => ({ CanvasView: () =>
      })); @@ -17,6 +17,7 @@ vi.mock("../ProjectMembers", () => ({ ProjectMembers: () =>
      })); vi.mock("../ProjectActivity", () => ({ ProjectActivity: () =>
      })); vi.mock("@/apps/FilesApp", () => ({ FilesApp: () =>
      })); vi.mock("@/apps/MessagesApp", () => ({ MessagesApp: () =>
      })); +vi.mock("../ProjectWorkspacePane", () => ({ ProjectWorkspacePane: () =>
      })); const fakeProject: Project = { id: "p1", @@ -62,11 +63,35 @@ describe("ProjectWorkspace tab strip", () => { expect(screen.queryByTestId("workspace-tab-pills-scroller")).not.toBeInTheDocument(); }); + it("defaults to the Workspace tab and renders the workspace pane", async () => { + (useIsMobile as ReturnType).mockReturnValue(false); + await act(async () => { + render( {}} />); + }); + expect(screen.getByRole("tab", { name: "workspace" })).toHaveAttribute("aria-selected", "true"); + expect(screen.getByTestId("workspace-pane")).toBeInTheDocument(); + }); + + it("switches tabs when a tab is clicked", async () => { + (useIsMobile as ReturnType).mockReturnValue(false); + await act(async () => { + render( {}} />); + }); + await act(async () => { + fireEvent.click(screen.getByRole("tab", { name: "members" })); + }); + expect(screen.getByRole("tab", { name: "members" })).toHaveAttribute("aria-selected", "true"); + expect(screen.queryByTestId("workspace-pane")).not.toBeInTheDocument(); + }); + it("renders the FAB on mobile when the Tasks tab is active", async () => { (useIsMobile as ReturnType).mockReturnValue(true); await act(async () => { render( {}} />); }); + await act(async () => { + fireEvent.click(screen.getByRole("tab", { name: "Tasks" })); + }); expect(screen.getByLabelText("Create task")).toBeInTheDocument(); }); diff --git a/desktop/src/apps/ProjectsApp/__tests__/ProjectsApp.mobile.test.tsx b/desktop/src/apps/ProjectsApp/__tests__/ProjectsApp.mobile.test.tsx index e95573b5..77ccafa0 100644 --- a/desktop/src/apps/ProjectsApp/__tests__/ProjectsApp.mobile.test.tsx +++ b/desktop/src/apps/ProjectsApp/__tests__/ProjectsApp.mobile.test.tsx @@ -42,10 +42,12 @@ describe("ProjectsApp mobile shell", () => { expect(screen.getByTestId("mobile-split-view")).toHaveAttribute("data-list-title", "Projects"); }); - it("renders side-by-side flex layout when useIsMobile is false", () => { + it("renders side-by-side layout (project-list sidebar + main) when useIsMobile is false", () => { (useIsMobile as ReturnType).mockReturnValue(false); render(); expect(screen.queryByTestId("mobile-split-view")).not.toBeInTheDocument(); - expect(document.querySelector("aside.w-72")).toBeInTheDocument(); + // ProjectList (the 248px sidebar) and the main detail column render together. + expect(screen.getByTestId("project-list")).toBeInTheDocument(); + expect(document.querySelector("main")).toBeInTheDocument(); }); }); diff --git a/desktop/src/apps/ProjectsApp/index.tsx b/desktop/src/apps/ProjectsApp/index.tsx index 21fd155b..a3381c47 100644 --- a/desktop/src/apps/ProjectsApp/index.tsx +++ b/desktop/src/apps/ProjectsApp/index.tsx @@ -52,7 +52,7 @@ export function ProjectsApp({ windowId: _windowId }: { windowId: string }) { {selected ? ( ) : ( -
      Select or create a project.
      +
      Select or create a project.
      )} ); @@ -69,13 +69,12 @@ export function ProjectsApp({ windowId: _windowId }: { windowId: string }) { ); } - // Desktop branch — byte-identical layout preserved + // Desktop branch: project-list sidebar + main column. ProjectList renders + // its own