Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 16 additions & 1 deletion docs/desk/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,9 +39,11 @@ loopback server.
|---|---|---|
| Floor | Home | Zones (clients) > rooms (projects) > agents. Zoom to a client by its name. |
| Project room | A room's name on the floor | One project: stage and focus, the mission board, crew, blockers, memory, and context (see Project room). Escape or the breadcrumb goes back to the floor. |
| Menu drawer | Menu icon | Missions: Inbox, Today, Missions board. Workspace: Floor, Projects, Agents, Approvals. Project: Context, Memory, Runs, Tasks, Notes. System: Settings. |
| Menu drawer | Menu icon | Missions: Inbox, Today, Missions board, Backlog, Timeline, Clients. Workspace: Floor, Projects, Agents, Approvals. Project: Context, Memory, Runs, Tasks, Notes. System: Settings. |
| Today | Menu > Missions > Today | Every client's day in one view (see Missions). Escape or the breadcrumb goes back to the floor. |
| Missions board | Menu > Missions > Missions board | Every client's tasks by station, a lane per client, project, or assignee. A card opens the task drawer. |
| Backlog, Timeline | Menu > Missions | The ranked backlog with milestones; the week on a calendar. |
| Clients, a dossier | Menu > Missions > Clients | Every client's pulse; one client's profile, projects, own work, interactions, and restricted records. |
| Assistant drawer | Assistant icon | Chat at workspace scope (local endpoints only, ADR-0021). |
| Agent drawer | An avatar | The agent card (state, detail, approvals), the coding session (start, stop, its changes, hand back), and a chat bound to its project. |

Expand Down Expand Up @@ -97,6 +99,18 @@ in the desk and the browser only; navigation from the menu, with views in the ma
client's own work. A card shows its scope, due date (late in red, today in orange), checklist
or milestone progress, who has it (an agent figure for `agent:<tool>`), and a raised hand when
blocked. Drag a card along its lane to move it.
- **Backlog**: open work grouped by client or by milestone, ranked by hand - drag a row, or
move it up or down - with the milestones and their progress on top. A move numbers the
group 1..n and writes only the ranks that change; the rank is the task's own field, so
agents read the same order.
- **Timeline**: the week, Monday first, with due work and reminders on their day and late work
in an "Earlier" column. Drag an item to another day to move its due date, or a reminder's
day at the same time.
- **Clients**: every client with its projects, open and late work, alerts, and last touch.
A client's dossier holds its profile (from `client.md`), working agreement and stakeholder
roles, projects, its own open work, the interaction log (`ws client log`), and - marked
"desk only" - its contacts and commercial terms, which the host reads from disk and no
agent ever receives.
- **Task drawer**: station, priority, due date, assignee, labels, blocker, checklist, the
description and acceptance criteria, a comment box, and the activity - every change by a
person or an agent. Every edit is one `ws task` command with the revision the drawer read; a
Expand Down Expand Up @@ -146,6 +160,7 @@ All under `/api/agent/desk/`, behind the host's token and loopback checks.
| `POST pm/tasks/edit` | `{ scope, id, revision, changes: { title?, priority?, due?, assignee?, parent?, remind_at?, recur?, kind?, rank?, labels_add?, labels_rm?, check_add?, accept_add? } }`: `ws task edit`; `none` clears |
| `POST pm/tasks/status` · `comment` · `check` · `block` · `unblock` | `ws task status|comment|check|block|unblock` with the revision; 409 when it is stale |
| `POST pm/remind` | `{ scope, text, at: "YYYY-MM-DD HH:MM", recur? }`: `ws remind` |
| `POST pm/clients/:client/log` | `{ text }`: `ws client log` |
| `GET pm/clients` | Every client folder with a `client.md`, with its pulse from `ws client show` |
| `GET pm/clients/:client` | `ws client show --json`, plus `restrictedText` (contacts, commercial) read by the host for the desk only - never a chat or an agent tool |
| `POST notes` | `{ project, title, body }` -> `{ path }`. Runs `ws note <project> <title> -` with the body on stdin; 400 with the reason on any refusal |
Expand Down
13 changes: 12 additions & 1 deletion server/desk/pm.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import assert from 'node:assert/strict';
import { mkdirSync, mkdtempSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { PmConflictError, clientKeys, pmAddTask, pmAlerts, pmBlock, pmCheck, pmClient, pmClients, pmComment, pmEditTask, pmRemind, pmSetStatus, pmTask, pmTasks, pmToday, snoozeAlert } from './pm.js';
import { PmConflictError, clientKeys, pmAddTask, pmLogInteraction, pmAlerts, pmBlock, pmCheck, pmClient, pmClients, pmComment, pmEditTask, pmRemind, pmSetStatus, pmTask, pmTasks, pmToday, snoozeAlert } from './pm.js';
import type { WsInputRunner } from './ws-input.js';

/** A `ws` that answers from `replies` by its first two words, and records every call. */
Expand Down Expand Up @@ -130,3 +130,14 @@ describe('desk project management writes', () => {
await assert.rejects(pmSetStatus(run, { scope: 'api', id: 'task_x', status: 'done', revision: REV }), PmConflictError);
});
});

describe('client interaction log', () => {
it('appends one line through ws client log, checked first', async () => {
const calls: string[][] = [];
const run: WsInputRunner = async (args) => { calls.push(args); return 'logged acme\n'; };
assert.deepEqual(await pmLogInteraction(run, 'acme', { text: 'Call: agreed phase 2' }), { client: 'acme' });
assert.deepEqual(calls, [['client', 'log', 'acme', 'Call: agreed phase 2']]);
await assert.rejects(pmLogInteraction(run, 'acme', { text: ' ' }), /the entry is empty/);
await assert.rejects(pmLogInteraction(run, '../x', { text: 'x' }), /invalid client/);
});
});
9 changes: 9 additions & 0 deletions server/desk/pm.ts
Original file line number Diff line number Diff line change
Expand Up @@ -334,3 +334,12 @@ export async function pmRemind(run: WsInputRunner, request: Record<string, unkno
}
return write(run, args);
}

/** One line in the client's interaction log - a meeting, a call, an agreement - via `ws client log`. */
export async function pmLogInteraction(run: WsInputRunner, key: unknown, request: Record<string, unknown>): Promise<{ client: string }> {
const client = clientKey(key);
const text = line(request.text, 'the entry', TEXT_MAX);
if (CONTROL_RE.test(text)) throw new Error('the entry contains control characters');
await run(['client', 'log', client, text], '');
return { client };
}
11 changes: 10 additions & 1 deletion server/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ import { TaskConflictError, addTask, setTaskBlocked, setTaskStatus } from './des
import { projectDetail } from './desk/project.js';
import {
PmConflictError, pmAddTask, pmAlerts, pmBlock, pmCheck, pmClient, pmClients, pmComment, pmEditTask, pmRemind,
pmSetStatus, pmTask, pmTasks, pmToday, realWsReader, snoozeAlert,
pmLogInteraction, pmSetStatus, pmTask, pmTasks, pmToday, realWsReader, snoozeAlert,
} from './desk/pm.js';
import { PlanConflictError, setPlanStatus } from './desk/plans.js';

Expand Down Expand Up @@ -652,6 +652,15 @@ for (const [path, action] of pmWrites) {
});
}

app.post(`${AGENT_API}/desk/pm/clients/:client/log`, async (c) => {
const body = await c.req.json().catch(() => ({})) as Record<string, unknown>;
try {
return c.json(await pmLogInteraction(realWsReader(WORKSPACE_ROOT), c.req.param('client'), body));
} catch (err) {
return c.json(pmFail(err), 400);
}
});

// The dossier, with the client's restricted records for the operator's eyes only: this
// route serves the desk, never a chat or an agent tool.
app.get(`${AGENT_API}/desk/pm/clients/:client`, async (c) => {
Expand Down
37 changes: 31 additions & 6 deletions src/desk/DeskApp.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,10 @@ import { SettingsDialog } from '../components/settings/SettingsDialog';
import { TodayView } from './pm/TodayView';
import { MissionsView } from './pm/MissionsView';
import { TaskDrawer } from './pm/TaskDrawer';
import { BacklogView } from './pm/BacklogView';
import { TimelineView } from './pm/TimelineView';
import { ClientsView } from './pm/ClientsView';
import { ClientPage } from './pm/ClientPage';
import { usePmAlerts } from './pm/usePm';
import { DATE_ALERTS, PM_PAGE_TITLE, type PmPage } from './pm/pages';
import type { Floor, FloorAgent, FloorRoom } from './types';
Expand Down Expand Up @@ -43,6 +47,8 @@ export function DeskApp() {
const [room, setRoom] = useState<string | null>(null);
/** A project management view filling the main area (Today, ...), or null. */
const [page, setPage] = useState<PmPage | null>(null);
/** The client whose dossier fills the main area, opened from Clients or a client scope. */
const [clientPage, setClientPage] = useState<string | null>(null);
const pm = usePmAlerts();
/** The task open in its drawer, and a counter its changes bump so the views follow. */
const [task, setTask] = useState<{ scope: string; id: string } | null>(null);
Expand Down Expand Up @@ -71,36 +77,39 @@ export function DeskApp() {
const openRoom = useCallback((target: FloorRoom) => {
setProject(target.key);
setPage(null);
setClientPage(null);
setRoom(target.key);
}, [setProject]);

// A task's scope: a project opens its room; a client's own work opens the floor at that client.
// A task's scope: a project opens its room; a client's own work opens the client's dossier.
const openScope = useCallback((scope: string) => {
setPage(null);
if (scope.startsWith('@')) {
setRoom(null);
setFocusClient(scope.slice(1));
setClientPage(scope.slice(1));
} else {
setClientPage(null);
setProject(scope);
setRoom(scope);
}
}, [setProject]);

const openPage = useCallback((next: PmPage) => {
setRoom(null);
setClientPage(null);
setPage(next);
setDrawer(null);
}, []);

// Escape leaves a room or a view for the floor, unless a drawer is open: then it closes the drawer.
useEffect(() => {
if (!room && !page) return;
if (!room && !page && !clientPage) return;
const onKey = (event: KeyboardEvent) => {
if (event.key === 'Escape' && !drawer && !task) { setRoom(null); setPage(null); }
if (event.key === 'Escape' && !drawer && !task) { setRoom(null); setPage(null); setClientPage(null); }
};
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [room, page, drawer, task]);
}, [room, page, clientPage, drawer, task]);

// The menu or the assistant takes the right-hand side; an open task gives way to it.
useEffect(() => {
Expand Down Expand Up @@ -157,7 +166,15 @@ export function DeskApp() {
<header className="desk-bar">
<div className="desk-crumbs">
<span className="desk-mark" aria-hidden="true" />
{page ? (
{clientPage ? (
<nav aria-label="Floor position" className="crumbs">
<button type="button" onClick={() => setClientPage(null)}>All</button>
<span aria-hidden="true">/</span>
<button type="button" onClick={() => openPage('clients')}>Clients</button>
<span aria-hidden="true">/</span>
<span aria-current="location">{clientPage}</span>
</nav>
) : page ? (
<nav aria-label="Floor position" className="crumbs">
<button type="button" onClick={() => setPage(null)}>All</button>
<span aria-hidden="true">/</span>
Expand Down Expand Up @@ -232,8 +249,16 @@ export function DeskApp() {
onSnooze={pm.snooze}
onOpenTask={openTask}
/>
) : clientPage ? (
<ClientPage client={clientPage} version={alertsVersion} onOpenProject={(key) => openScope(key)} onOpenTask={openTask} />
) : page === 'missions' ? (
<MissionsView projects={projectKeys} version={alertsVersion} onOpenTask={openTask} />
) : page === 'backlog' ? (
<BacklogView projects={projectKeys} version={alertsVersion} onOpenTask={openTask} />
) : page === 'timeline' ? (
<TimelineView version={alertsVersion} onOpenTask={openTask} />
) : page === 'clients' ? (
<ClientsView version={alertsVersion} onOpenClient={(key) => { setPage(null); setClientPage(key); }} />
) : floor ? (
<FloorView
floor={floor}
Expand Down
58 changes: 58 additions & 0 deletions src/desk/desk.css
Original file line number Diff line number Diff line change
Expand Up @@ -1325,6 +1325,64 @@
.task-fields { grid-template-columns: 1fr; }
}

/* ── Backlog, Timeline, Clients ───────────────────────────────────────── */
.milestones { list-style: none; margin: 0; padding: 0; display: grid; grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); gap: 8px; }
.milestone { width: 100%; display: flex; flex-direction: column; gap: 6px; padding: 10px 12px; border-radius: 12px; border: 3px double var(--d-room-line); background: var(--d-bg); color: inherit; font: inherit; text-align: left; cursor: pointer; }
.milestone:hover { border-color: var(--d-accent); }
.milestone-title { display: flex; gap: 6px; align-items: center; justify-content: space-between; font-size: 13.5px; font-weight: 600; }
.milestone-bar { height: 6px; border-radius: 999px; background: var(--d-hover); overflow: hidden; }
.milestone-bar i { display: block; height: 100%; border-radius: 999px; background: var(--d-accent); }
.milestone-meta { font-size: 11.5px; color: var(--d-ink-3); }

.backlog-list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; }
.backlog-row { position: relative; display: grid; grid-template-columns: 18px minmax(0, 1fr) auto 64px 96px 120px auto; gap: 10px; align-items: center; padding: 6px 4px 6px 10px; border-bottom: 1px solid var(--d-room-line); font-size: 12.5px; cursor: grab; }
.backlog-row:last-child { border-bottom: none; }
.backlog-row::before { content: ""; position: absolute; left: 0; top: 8px; bottom: 8px; width: 3px; border-radius: 0 3px 3px 0; background: var(--d-line); }
.backlog-row.priority-high::before { background: var(--d-attn); }
.backlog-row.priority-urgent::before { background: var(--d-warn); }
.backlog-row.is-dragging { opacity: .5; }
.backlog-grip { color: var(--d-ink-3); letter-spacing: -2px; }
.backlog-title { text-align: left; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.backlog-cell { color: var(--d-ink-2); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.backlog-moves { display: flex; gap: 2px; }
.backlog-moves .icon-btn { width: 24px; height: 24px; font-size: 12px; }

.tl-nav { display: flex; gap: 6px; flex-wrap: wrap; }
.tl-grid { display: grid; grid-template-columns: repeat(8, minmax(130px, 1fr)); gap: 8px; overflow-x: auto; padding-bottom: 6px; }
.tl-day { min-height: 220px; padding: 8px; border-radius: 12px; background: var(--d-room); border: 1px solid var(--d-room-line); display: flex; flex-direction: column; gap: 6px; }
.tl-day.is-today { border: 2px solid var(--d-accent); }
.tl-day.is-earlier { background: color-mix(in srgb, var(--d-warn) 5%, var(--d-room)); border-style: dashed; }
.tl-day.is-target { border-color: var(--d-accent); }
.tl-head { margin: 0; display: flex; justify-content: space-between; align-items: baseline; font-size: 12px; font-weight: 650; color: var(--d-ink-2); }
.tl-head small { font-weight: 500; color: var(--d-ink-3); }
.tl-day ol { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 4px; }
.tl-item { padding: 5px 7px; border-radius: 8px; background: var(--d-bg); border: 1px solid var(--d-room-line); cursor: grab; }
.tl-item.is-reminder { border-color: color-mix(in srgb, var(--d-attn) 45%, transparent); }
.tl-item.is-done { opacity: .55; }
.tl-title { font-size: 12px; color: var(--d-ink); overflow-wrap: anywhere; }
.tl-title b { color: var(--d-attn); }
.tl-scope { font-size: 10.5px; color: var(--d-ink-3); }

.client-cards { list-style: none; margin: 0; padding: 0; display: grid; grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); gap: 10px; }
.client-card { width: 100%; display: flex; flex-direction: column; gap: 4px; padding: 14px; border-radius: 14px; text-align: left; cursor: pointer; font: inherit; color: inherit;
background: var(--d-room); border: 1px solid var(--d-room-line); border-top: 3px solid var(--d-accent); }
.client-card:hover { border-color: var(--d-accent); }
.client-card-key { font-size: 12px; letter-spacing: .14em; text-transform: uppercase; font-weight: 700; color: var(--d-ink-2); }
.client-card-name { font-size: 15px; font-weight: 600; }
.client-card-stats { display: flex; flex-wrap: wrap; gap: 8px; font-size: 12px; color: var(--d-ink-2); }
.client-card-stats .is-warn { color: var(--d-warn); font-weight: 650; }
.client-card-stats .is-attn { color: var(--d-attn); font-weight: 650; }
.client-card-when { font-size: 11.5px; color: var(--d-ink-3); }
.client-main { display: flex; flex-direction: column; gap: 12px; min-width: 0; }
.restricted { border-style: dashed; }
.restricted-badge { margin-left: 6px; padding: 1px 7px; border-radius: 999px; font-size: 10px; letter-spacing: .08em; color: var(--d-attn); border: 1px solid color-mix(in srgb, var(--d-attn) 50%, transparent); }
.restricted-text { margin: 0; max-height: 260px; overflow: auto; white-space: pre-wrap; font-family: var(--font-mono); font-size: 11.5px; color: var(--d-ink-2); }

@media (max-width: 900px) {
.backlog-row { grid-template-columns: 18px minmax(0, 1fr) auto auto; }
.backlog-row .backlog-cell { display: none; }
}

/* ── Small screens ───────────────────────────────────────────────────── */
@media (max-width: 720px) {
.desk-main { padding: 72px 12px 64px; }
Expand Down
18 changes: 18 additions & 0 deletions src/desk/drawers/MenuDrawer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,24 @@ function Nav({ floor, project, onSection, onFloor, alerts, onPage }: Props) {
<ChevronIcon />
</button>
</li>
<li>
<button type="button" className="nav-item" onClick={() => onPage('backlog')}>
<span>Backlog</span>
<ChevronIcon />
</button>
</li>
<li>
<button type="button" className="nav-item" onClick={() => onPage('timeline')}>
<span>Timeline</span>
<ChevronIcon />
</button>
</li>
<li>
<button type="button" className="nav-item" onClick={() => onPage('clients')}>
<span>Clients</span>
<ChevronIcon />
</button>
</li>
</ul>
<p className="nav-group">Workspace</p>
<ul className="nav-list">
Expand Down
Loading
Loading