Merge pull request 'feat(session): impeccable pass + tasklane keyboard flow' (#158) from feat/session-distill-quieter into main
All checks were successful
CI / frontend (push) Successful in 5m8s
Mirror to GitHub / mirror (push) Successful in 6s
CI / backend (push) Successful in 10m20s
CI / e2e (push) Successful in 10m43s

Reviewed-on: #158
-Michael Chihlas
This commit was merged in pull request #158.
This commit is contained in:
2026-05-01 21:53:13 +00:00
35 changed files with 788 additions and 564 deletions

View File

@@ -2,32 +2,24 @@
# HANDOFF.md # HANDOFF.md
**Last updated:** 2026-05-01 (session 6PR #156 QA'd, merged, branch deleted) **Last updated:** 2026-05-01 (session 9started issue cleanup plan sections 1 and 2)
**Active task:** None. Pick next from `.ai/TODO.md` or roadmap. **Active task:** None. Pick next from `.ai/TODO.md` or roadmap.
**Just-merged:** PR #156 (suggested-fix `applied_pending` non-terminal outcome) merged into `main` as `3ba4532`. **Just-updated:** issue cleanup plan sections 1 and 2 were started and documented.
## Where this session ended ## Where this session ended
PR #156 QA'd in the dev environment and merged. Issue cleanup plan follow-up completed:
1. Working tree had two commits' worth of pending work: the prior session's local review fixes (5 source files + 3 `.ai/` notes describing them) and this session's docker-exec docs (`.ai/PROJECT_CONTEXT.md` + `AGENTS.md`). Committed each as a separate logical commit, attributed to the agent that authored each. - Section 1: frontend lint is clean. Stale lint disables from the warning set were removed or replaced with justified comments, hook dependency warnings were resolved, e2e selectors were added for session history and the FlowPilot command-palette entry, and `AssistantChatPage` now logs unexpected `currentChatRef` stale async discards.
2. Browser QA via `/qa`: 5 of 7 scripted checks PASS with concrete DB-level + UI-level evidence — PendingBanner rendering, "It worked" / "Update reason" / "Dismiss" actions, page-level Resolve auto-patch, Escalate intercept with new generalized copy. 2 entry-path checks (VerifyingBanner overflow → "Waiting to verify…", nudge "Still checking") deferred because they require live AI-generated chat state. The mutating handlers behind those entry paths are verified via the tested transitions, so risk is rendering-only. - Section 2: `TaskLane` action cards now have diagnostic help affordances for common commands (connectivity, DNS, IP config, event logs, services, and generic checks). #128 was documented as "keep existing responsive side-panel/bottom-drawer behavior unless pilot feedback proves a preference is needed."
3. Pushed `feat/fix-pending-verification` to remote. Required Gitea CI checks (`CI / frontend`, `CI / backend`) plus `CI / e2e` all green at merge. Merged via Gitea API as a merge commit (`3ba4532`). - Updated `docs/plans/2026-05-01-issue-cleanup-plan.md` with section 1/2 status and validation.
4. Local `main` fast-forwarded to remote; `feat/fix-pending-verification` deleted locally and on the remote. - Validation passed: `docker exec -w /app resolutionflow_frontend npm run lint`, `docker exec -w /app resolutionflow_frontend npx tsc -b`, and `docker exec -w /app resolutionflow_frontend npm run build` (existing Vite large-chunk warning only).
**Validation evidence:**
- `/gstack/qa-reports/qa-report-pending-verification-2026-04-30.md` — full report with screenshots in `screenshots/`.
- Gitea PR #156 state: `closed`, `merge_commit_sha=3ba45326`, `merged_at=2026-05-01T03:42:10Z`.
## Resume point — DO THIS NEXT ## Resume point — DO THIS NEXT
Pick a task from `.ai/TODO.md` or `03-DEVELOPMENT-ROADMAP.md`. Two non-blocking follow-ups for the just-shipped feature: If tracker auth is available, close #127 and close/archive stale PR #124; rewrite #66 to template packs / one-click install only. Then continue the plan at section 3: #58 structured "step is wrong" quality signals. After that, section 4 is #60 recurring issue detection and section 5 is #129 hierarchical guide navigation.
- Drive checks 1 and 5 from the QA report in real pilot usage to close the entry-path UI rendering gap.
- Watch whether engineers lose track of multiple pending fixes across sessions; if so, revisit the cross-session "Follow-ups" rollup that was scoped out of PR #156.
## Environment notes (carry-forward) ## Environment notes (carry-forward)

View File

@@ -12,6 +12,33 @@
--- ---
## 2026-05-01 07:20 UTC — Codex — Start issue cleanup plan sections 1 and 2
- Started `docs/plans/2026-05-01-issue-cleanup-plan.md` sections 1 and 2.
- Cleaned frontend lint to zero warnings by removing stale lint disables, tightening hook dependencies, and adding justified comments where effects are intentionally keyed to route or owner identity.
- Added e2e selectors for session history controls and the FlowPilot command-palette entry.
- Added `AssistantChatPage` observability for unexpected `currentChatRef` stale async discards.
- Added `TaskLane` diagnostic help affordances for common command categories and documented #128 as "keep the existing responsive side-panel/bottom-drawer behavior until pilot feedback says otherwise."
- Verified `npm run lint`, `npx tsc -b`, and `npm run build` in `resolutionflow_frontend`; build only reported the existing Vite large-chunk warning.
- Files touched: frontend lint-cleanup files, `frontend/src/components/assistant/TaskLane.tsx`, `frontend/src/pages/AssistantChatPage.tsx`, `frontend/src/pages/SessionHistoryPage.tsx`, `frontend/src/components/layout/CommandPalette.tsx`, `docs/plans/2026-05-01-issue-cleanup-plan.md`, `.ai/HANDOFF.md`, `.ai/SESSION_LOG.md`.
## 2026-05-01 06:05 UTC — Codex — Clean stale TODOs and add issue cleanup plan
- Removed the resolved pytest-xdist item from `.ai/TODO.md` and reset "Up next" to no selected task.
- Removed the resolved "Add role gate to handoff claim endpoint" backlog item from `.ai/TODO.md`.
- Updated the frontend lint cleanup TODO from 23 warnings to the current `npm run lint` result: 24 warnings, 0 errors.
- Tried to close Gitea #127 through the API, but this environment has no Gitea token; API returned `401 token is required`.
- Added `docs/plans/2026-05-01-issue-cleanup-plan.md` with safe tracker actions and a recommended order for clearing remaining issues.
- Files touched: `.ai/TODO.md`, `.ai/HANDOFF.md`, `.ai/SESSION_LOG.md`, `docs/plans/2026-05-01-issue-cleanup-plan.md`.
## 2026-05-01 05:40 UTC — Codex — Audit TODO backlog and Gitea issue validity
- Compared `.ai/TODO.md`, inline code TODOs, and open Gitea issues against current `main`.
- Verified pytest-xdist is already shipped (`backend/requirements-dev.txt`, `backend/tests/conftest.py`, `.gitea/workflows/ci.yml`) so the `.ai/TODO.md` xdist item is stale. Ran frontend lint in Docker; current state is `0 errors, 24 warnings`, so the lint cleanup item remains valid but its count is stale.
- Verified Gitea issue status: #58, #60, #128, #129, #130 remain valid; #66 is partially resolved by current `.rfflow` import/export and should be narrowed to template packs/marketplace; #127 is mostly resolved by current UI copy and prompt boundaries unless an always-visible scope badge is still wanted. Open PR #124 is stale/unmergeable against current `main`.
- Verified inline TODOs still valid: post-session contextual feedback prompt, FlowPilot analytics domain/time-entry placeholders, prompt-cache verification note unless live telemetry has confirmed it, proposal `modify` flow editor wiring, and procedural ghost-step accept/dismiss buttons.
- Files touched: `.ai/HANDOFF.md`, `.ai/SESSION_LOG.md`.
## 2026-05-01 03:45 UTC — Claude Opus 4.7 — QA, merge, and ship PR #156 pending-verification ## 2026-05-01 03:45 UTC — Claude Opus 4.7 — QA, merge, and ship PR #156 pending-verification
- Committed two logical units of pending work on `feat/fix-pending-verification`: prior session's local review fixes as `5bee264` (Codex-attributed, 5 source files + 3 `.ai/` notes) and this session's docker-exec docs as `15042af` (Claude-attributed, `.ai/PROJECT_CONTEXT.md` + `AGENTS.md`). Cleaned up a 20MB `core.22120` Chromium dump left behind by an earlier sandbox crash. - Committed two logical units of pending work on `feat/fix-pending-verification`: prior session's local review fixes as `5bee264` (Codex-attributed, 5 source files + 3 `.ai/` notes) and this session's docker-exec docs as `15042af` (Claude-attributed, `.ai/PROJECT_CONTEXT.md` + `AGENTS.md`). Cleaned up a 20MB `core.22120` Chromium dump left behind by an earlier sandbox crash.

View File

@@ -5,11 +5,11 @@
## Up next ## Up next
- [ ] **Parallelize backend pytest with pytest-xdist.** ✅ landing as PR #151. Verified locally: backend suite 22 min → 4m 28s with `-n auto` on the 8-core homelab runner. Per-worker DB isolation via `PYTEST_XDIST_WORKER` in conftest.py. None selected. Pick from the backlog below or `03-DEVELOPMENT-ROADMAP.md`.
## Backlog ## Backlog
- [ ] **Frontend lint warnings cleanup.** 23 `react-hooks/exhaustive-deps` warnings remain after PR #149 (mostly missing-deps in useEffect). Either fix them or audit them for known-safe ones and add eslint-disable comments. Not blocking CI today. - [ ] **Frontend lint warnings cleanup.** `npm run lint` currently reports 24 warnings (0 errors): mostly `react-hooks/exhaustive-deps` plus a few unused eslint-disable directives. Either fix them or audit known-safe ones and add/remove eslint-disable comments intentionally. Not blocking CI today.
- [ ] **Audit `filterwarnings` ignores added in `wip(handoff): restore backend suite to green`.** Codex added narrow `ResourceWarning` filters for unclosed socket/transport/event-loop noise from pytest-asyncio teardown. Worth periodically reviewing whether those are still needed (e.g. when bumping pytest-asyncio) — if a real warning appears in those forms it would be silenced. - [ ] **Audit `filterwarnings` ignores added in `wip(handoff): restore backend suite to green`.** Codex added narrow `ResourceWarning` filters for unclosed socket/transport/event-loop noise from pytest-asyncio teardown. Worth periodically reviewing whether those are still needed (e.g. when bumping pytest-asyncio) — if a real warning appears in those forms it would be silenced.
- [ ] **Add `data-testid` attributes to e2e-critical interactive elements.** PR #152 fixed five Playwright tests by chasing UI-text changes (`Sessions``Session History`, `Account Settings``Account Management`, `/assistant``/pilot`, "Flow Sessions" tab, Resume button on session cards). Each was a one-line selector update, but every UI churn re-breaks them. Adding stable `data-testid` attributes on the targeted elements (page heading wrappers, tab nav, primary action buttons) and switching tests to `getByTestId` would make these immune to copy/route renames. Scope it small — start with `SessionHistoryPage` heading, the AI/Flow Sessions tab buttons, the per-session `Resume` button, and the command-palette FlowPilot option. - [ ] **Add `data-testid` attributes to e2e-critical interactive elements.** PR #152 fixed five Playwright tests by chasing UI-text changes (`Sessions``Session History`, `Account Settings``Account Management`, `/assistant``/pilot`, "Flow Sessions" tab, Resume button on session cards). Each was a one-line selector update, but every UI churn re-breaks them. Adding stable `data-testid` attributes on the targeted elements (page heading wrappers, tab nav, primary action buttons) and switching tests to `getByTestId` would make these immune to copy/route renames. Scope it small — start with `SessionHistoryPage` heading, the AI/Flow Sessions tab buttons, the per-session `Resume` button, and the command-palette FlowPilot option.
- [ ] **Per-test transactional rollback in `test_db` fixture.** Bigger engineering than xdist (which we already shipped). Instead of `DROP SCHEMA public CASCADE` per test, wrap each test in a savepoint and rollback at teardown. ~30-40% additional speedup on top of xdist for test-DB-heavy tests. Real refactor; only worth it if the suite gets significantly larger or runs more frequently. - [ ] **Per-test transactional rollback in `test_db` fixture.** Bigger engineering than xdist (which we already shipped). Instead of `DROP SCHEMA public CASCADE` per test, wrap each test in a savepoint and rollback at teardown. ~30-40% additional speedup on top of xdist for test-DB-heavy tests. Real refactor; only worth it if the suite gets significantly larger or runs more frequently.
@@ -20,4 +20,6 @@
- [ ] **Mobile/responsive design for EscalationQueue + handoff-context screen.** Pre-PMF wedge demo targets desktop only — MSP techs work on laptops/desktops in shop environments. Once 3+ paying customers exist and a tech requests mobile (likely on-call use case), spec the responsive behavior: stacked card layout below `sm:` breakpoint, full-bleed handoff-context overlay on mobile, swipe-to-claim gesture instead of Pick Up button. Surfaced from /plan-design-review on the Escalation-Mode wedge plan. - [ ] **Mobile/responsive design for EscalationQueue + handoff-context screen.** Pre-PMF wedge demo targets desktop only — MSP techs work on laptops/desktops in shop environments. Once 3+ paying customers exist and a tech requests mobile (likely on-call use case), spec the responsive behavior: stacked card layout below `sm:` breakpoint, full-bleed handoff-context overlay on mobile, swipe-to-claim gesture instead of Pick Up button. Surfaced from /plan-design-review on the Escalation-Mode wedge plan.
- [ ] **(MOVED IN-SCOPE for Escalation Mode v1, 2026-04-27)** ~~Add role gate to handoff claim endpoint.~~ Codex review correctly flagged this as wedge-relevant (the race-condition story depends on auth gating). Now part of the Escalation Mode v1 build, not a deferred TODO. - [ ] **`bg-card-hover` Tailwind class doesn't resolve.** [`frontend/src/components/layout/CommandPalette.tsx:450-451`](../frontend/src/components/layout/CommandPalette.tsx) uses `bg-card-hover` as a Tailwind utility, but Tailwind v4 generates `bg-{token}` from `--color-{token}` — and the token in [`frontend/src/index.css:15`](../frontend/src/index.css) is `--color-bg-card-hover`, which generates `bg-bg-card-hover`, not `bg-card-hover`. So those classes silently produce nothing. Other call sites (KnowledgeBaseCards, TeamSummary, ProposalBanner) use the explicit `hover:bg-[var(--color-bg-card-hover)]` form which works. Fix: change the CommandPalette classes to the explicit-var form, OR add a `--color-card-hover` semantic mapping in index.css alongside `--color-card`. Surfaced 2026-05-01 during impeccable polish sweep.
- [ ] **`ConcludeSessionModal` paused/escalated step forces single-artifact choice — should allow multi-select.** [`frontend/src/components/assistant/ConcludeSessionModal.tsx`](../frontend/src/components/assistant/ConcludeSessionModal.tsx) ~lines 430-474 ("Paused/Escalated: status update options"). Today the engineer clicks ONE of Ticket Notes / Client Update / Email Draft, the buttons disappear, and the result replaces them. Real MSP escalations almost always need at least two: technical notes for the next engineer's PSA AND a non-technical client update. Same for pause (client update + ticket notes for context when resuming). Recommended shape: multi-select with smart defaults — three checkboxes (`☑ Ticket Notes ☑ Client Update ☐ Email Draft`); for `escalated` pre-check Ticket Notes + Client Update; for `paused` pre-check Client Update only. One "Generate" button fires all selected in parallel via existing `aiSessionsApi.generateStatusUpdate(...)` (already supports the three `audience` values: `ticket_notes`, `client_update`, `email_draft`). Each result renders in its own card with its own Copy / Post-to-PSA / Send-Email action. Surfaced 2026-05-01. Feature work, not polish — touches streaming wiring for parallel calls.

View File

@@ -0,0 +1,81 @@
# Issue Cleanup Plan - 2026-05-01
## Tracker Hygiene
These are safe tracker updates before any feature work:
1. Close Gitea #127 (`feat: show AI content scope indicator`) unless an always-visible badge is still desired.
- Current code already has IT/MSP scope copy in the assistant empty state.
- `ASSISTANT_SYSTEM_PROMPT` also has an off-domain redirect boundary.
2. Rewrite Gitea #66 (`Tree Templates + Import/Export`) to the remaining scope only.
- `.rfflow` export/import is implemented in `tree_transfer.py` and exposed in the library UI.
- Remaining work: curated packs, authenticated one-click install from gallery, template versioning, marketplace/community path.
3. Close or archive open PR #124 (`feat/cockpit-harness`).
- It is unmergeable against current `main` and overlaps newer `/pilot` work.
4. Keep Gitea #58, #60, #128, #129, #130 open.
- They still describe real product gaps.
## Recommended Order
### 1. Low-Risk Maintenance
- Status: started 2026-05-01.
- Frontend lint is clean after removing stale disable comments and tightening hook dependencies.
- Added `data-testid` selectors for e2e-critical session history and FlowPilot command-palette controls.
- Added `AssistantChatPage` observability for unexpected `currentChatRef` guard mismatches so stale async discards are visible in the console.
Why first: these reduce future regression cost and are small, well-bounded changes.
### 2. Pilot UX Friction
- Status: started 2026-05-01.
- #130: Added diagnostic command help affordances in `TaskLane` action cards. Each active diagnostic card can explain what it checks, what to look for, and when to use it.
- #128: Keep the existing responsive drawer behavior for now. `TaskLane` already uses a side panel on wide screens and a bottom drawer below the desktop breakpoint; do not add a top/side preference unless pilot feedback shows the current responsive layout is blocking workflow.
- EscalationQueue mobile design stays deferred until a customer asks for it.
Why second: this improves the current FlowPilot wedge without changing core data models.
Validation run:
- `docker exec -w /app resolutionflow_frontend npm run lint`
- `docker exec -w /app resolutionflow_frontend npx tsc -b`
- `docker exec -w /app resolutionflow_frontend npm run build`
### 3. Workflow Quality Signals
- #58: Add structured "step is wrong" flags separate from thumbs-up/down helpfulness.
- Existing `StepFeedback` is not enough; it only records helpful/unhelpful and cannot capture incorrect/outdated/unclear/missing-info reasons.
Why third: useful, but needs schema/API/UI/admin surfaces.
### 4. Client Intelligence
- #60: Recurring issue detection.
- Start with a read-only banner using existing `sessions.client_name + tree_id` filters.
- Add same-resolution detection only after confirming the available session outcome/node data is reliable enough.
Why fourth: high value, but it touches session-start and close-out flows and needs careful false-positive handling.
### 5. Documentation Structure
- #129: Hierarchical guide navigation.
- Current `/guides` route is a card grid plus detail pages with sections and breadcrumbs, but not a collapsible guide tree.
Why fifth: valid UX request, but less urgent than pilot workflow gaps.
## Gitea Actions Needed
The current environment does not have a Gitea token configured, so API writes fail with `401 token is required`. Once authenticated:
```bash
curl -X PATCH \
https://gitea.resolutionflow.com/api/v1/repos/chihlasm/resolutionflow/issues/127 \
-H "Authorization: token $GITEA_TOKEN" \
-H "Content-Type: application/json" \
-d '{"state":"closed"}'
```
For #66, prefer editing the title/body instead of closing it:
- Title: `feat: curated template packs and one-click install`
- Body: remove completed `.rfflow` export/import acceptance criteria and keep pack/install/versioning work.

View File

@@ -16,7 +16,7 @@ function App() {
} else { } else {
setLoading(false) setLoading(false)
} }
}, []) }, [fetchUser, isAuthenticated, setLoading])
return <RouterProvider router={router} /> return <RouterProvider router={router} />
} }

View File

@@ -39,7 +39,7 @@ export function FlowAnalyticsPanel({ treeId }: FlowAnalyticsPanelProps) {
useEffect(() => { useEffect(() => {
// eslint-disable-next-line react-hooks/set-state-in-effect // eslint-disable-next-line react-hooks/set-state-in-effect
setLoading(true) setLoading(true)
// eslint-disable-next-line react-hooks/set-state-in-effect
setError(false) setError(false)
analyticsApi analyticsApi
.getFlowAnalytics(treeId, period) .getFlowAnalytics(treeId, period)

View File

@@ -74,7 +74,7 @@ export function ActionCardGroup({ actions, onSubmit, disabled, stale }: ActionCa
onClick={() => setExpanded(true)} onClick={() => setExpanded(true)}
className="w-full rounded-lg border border-default/50 bg-elevated/20 p-2.5 flex items-center justify-between text-left hover:bg-elevated/40 transition-colors group" className="w-full rounded-lg border border-default/50 bg-elevated/20 p-2.5 flex items-center justify-between text-left hover:bg-elevated/40 transition-colors group"
> >
<div className="flex items-center gap-2 text-[0.75rem] text-muted-foreground"> <div className="flex items-center gap-2 text-xs text-muted-foreground">
<Terminal size={12} /> <Terminal size={12} />
<span>{pendingCount} diagnostic check{pendingCount !== 1 ? 's' : ''} not completed</span> <span>{pendingCount} diagnostic check{pendingCount !== 1 ? 's' : ''} not completed</span>
</div> </div>
@@ -95,7 +95,7 @@ export function ActionCardGroup({ actions, onSubmit, disabled, stale }: ActionCa
</div> </div>
<div className="space-y-0.5"> <div className="space-y-0.5">
{responses.map((r, i) => ( {responses.map((r, i) => (
<div key={i} className="flex items-center gap-2 text-[0.75rem] text-muted-foreground"> <div key={i} className="flex items-center gap-2 text-xs text-muted-foreground">
{r.state === 'done' ? ( {r.state === 'done' ? (
<Check size={10} className="text-success shrink-0" /> <Check size={10} className="text-success shrink-0" />
) : ( ) : (
@@ -118,7 +118,7 @@ export function ActionCardGroup({ actions, onSubmit, disabled, stale }: ActionCa
<div> <div>
<button <button
onClick={() => setShowRunAll(!showRunAll)} onClick={() => setShowRunAll(!showRunAll)}
className="flex items-center gap-1.5 text-[0.75rem] font-medium text-accent-text hover:text-accent transition-colors" className="flex items-center gap-1.5 text-xs font-medium text-accent-text hover:text-accent transition-colors"
> >
<Terminal size={12} /> <Terminal size={12} />
<span>Run All ({commandActions.length} commands)</span> <span>Run All ({commandActions.length} commands)</span>
@@ -128,12 +128,12 @@ export function ActionCardGroup({ actions, onSubmit, disabled, stale }: ActionCa
{showRunAll && ( {showRunAll && (
<div className="mt-2 rounded-lg border border-default bg-code p-3"> <div className="mt-2 rounded-lg border border-default bg-code p-3">
<div className="flex items-center justify-between mb-2"> <div className="flex items-center justify-between mb-2">
<span className="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground"> <span className="text-[0.625rem] font-semibold uppercase tracking-wider text-muted-foreground">
Combined diagnostic script Combined diagnostic script
</span> </span>
<button <button
onClick={() => handleCopyCommand(combinedScript)} onClick={() => handleCopyCommand(combinedScript)}
className="flex items-center gap-1 text-[0.75rem] text-muted-foreground hover:text-heading transition-colors" className="flex items-center gap-1 text-xs text-muted-foreground hover:text-heading transition-colors"
> >
<Copy size={11} /> <Copy size={11} />
<span>Copy</span> <span>Copy</span>
@@ -167,23 +167,23 @@ export function ActionCardGroup({ actions, onSubmit, disabled, stale }: ActionCa
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<div className="text-[0.8125rem] font-medium text-heading">{action.label}</div> <div className="text-[0.8125rem] font-medium text-heading">{action.label}</div>
{action.description && ( {action.description && (
<div className="text-[0.75rem] text-muted-foreground mt-0.5">{action.description}</div> <div className="text-xs text-muted-foreground mt-0.5">{action.description}</div>
)} )}
</div> </div>
{/* Status badge for handled cards */} {/* Status badge for handled cards */}
{response.state === 'done' && ( {response.state === 'done' && (
<span className="shrink-0 text-[10px] font-semibold uppercase tracking-wider text-success">Done</span> <span className="shrink-0 text-[0.625rem] font-semibold uppercase tracking-wider text-success">Done</span>
)} )}
{response.state === 'skipped' && ( {response.state === 'skipped' && (
<span className="shrink-0 text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">Skipped</span> <span className="shrink-0 text-[0.625rem] font-semibold uppercase tracking-wider text-muted-foreground">Skipped</span>
)} )}
</div> </div>
{/* Command with copy button */} {/* Command with copy button */}
{action.command && response.state !== 'skipped' && ( {action.command && response.state !== 'skipped' && (
<div className="mt-2 flex items-center gap-2 rounded bg-code px-2.5 py-1.5"> <div className="mt-2 flex items-center gap-2 rounded bg-code px-2.5 py-1.5">
<code className="flex-1 text-[0.75rem] font-mono text-heading truncate"> <code className="flex-1 text-xs font-mono text-heading truncate">
{action.command} {action.command}
</code> </code>
<button <button
@@ -201,20 +201,20 @@ export function ActionCardGroup({ actions, onSubmit, disabled, stale }: ActionCa
<div className="mt-2 flex flex-col sm:flex-row items-stretch sm:items-center gap-2"> <div className="mt-2 flex flex-col sm:flex-row items-stretch sm:items-center gap-2">
<button <button
onClick={() => updateCard(idx, { state: 'pasting' })} onClick={() => updateCard(idx, { state: 'pasting' })}
className="flex items-center justify-center gap-1 rounded-md border border-accent/40 bg-accent-dim/30 px-2.5 py-2 sm:py-1 text-[0.75rem] font-medium text-accent-text hover:bg-accent-dim/50 transition-colors min-h-[44px] sm:min-h-0" className="flex items-center justify-center gap-1 rounded-md border border-accent/40 bg-accent-dim/30 px-2.5 py-2 sm:py-1 text-xs font-medium text-accent-text hover:bg-accent-dim/50 transition-colors min-h-[44px] sm:min-h-0"
> >
<Clipboard size={11} /> <Clipboard size={11} />
Paste Result Paste Result
</button> </button>
<button <button
onClick={() => updateCard(idx, { state: 'typing' })} onClick={() => updateCard(idx, { state: 'typing' })}
className="flex items-center justify-center gap-1 rounded-md border border-default bg-elevated/50 px-2.5 py-2 sm:py-1 text-[0.75rem] font-medium text-heading hover:bg-elevated transition-colors min-h-[44px] sm:min-h-0" className="flex items-center justify-center gap-1 rounded-md border border-default bg-elevated/50 px-2.5 py-2 sm:py-1 text-xs font-medium text-heading hover:bg-elevated transition-colors min-h-[44px] sm:min-h-0"
> >
Type Answer Type Answer
</button> </button>
<button <button
onClick={() => updateCard(idx, { state: 'skipped' })} onClick={() => updateCard(idx, { state: 'skipped' })}
className="flex items-center justify-center gap-1 rounded-md px-2.5 py-2 sm:py-1 text-[0.75rem] text-muted-foreground hover:text-heading transition-colors min-h-[44px] sm:min-h-0" className="flex items-center justify-center gap-1 rounded-md px-2.5 py-2 sm:py-1 text-xs text-muted-foreground hover:text-heading transition-colors min-h-[44px] sm:min-h-0"
> >
<SkipForward size={11} /> <SkipForward size={11} />
Skip Skip
@@ -237,14 +237,14 @@ export function ActionCardGroup({ actions, onSubmit, disabled, stale }: ActionCa
<button <button
onClick={() => updateCard(idx, { state: 'done' })} onClick={() => updateCard(idx, { state: 'done' })}
disabled={!response.value.trim()} disabled={!response.value.trim()}
className="flex items-center gap-1 rounded-md bg-accent px-2.5 py-1 text-[0.75rem] font-medium text-white disabled:opacity-40 hover:bg-accent-hover transition-colors" className="flex items-center gap-1 rounded-md bg-accent px-2.5 py-1 text-xs font-medium text-white disabled:opacity-40 hover:bg-accent-hover transition-colors"
> >
<Check size={11} /> <Check size={11} />
Done Done
</button> </button>
<button <button
onClick={() => updateCard(idx, { state: 'pending', value: '' })} onClick={() => updateCard(idx, { state: 'pending', value: '' })}
className="text-[0.75rem] text-muted-foreground hover:text-heading transition-colors" className="text-xs text-muted-foreground hover:text-heading transition-colors"
> >
Cancel Cancel
</button> </button>
@@ -282,7 +282,7 @@ export function ActionCardGroup({ actions, onSubmit, disabled, stale }: ActionCa
</button> </button>
{submitError && ( {submitError && (
<div className="flex items-center gap-1.5 text-[0.75rem] text-danger"> <div className="flex items-center gap-1.5 text-xs text-danger">
<AlertCircle size={12} /> <AlertCircle size={12} />
<span>Failed to send</span> <span>Failed to send</span>
<button <button

View File

@@ -1,4 +1,4 @@
import { Sparkles, User } from 'lucide-react' import { Sparkles, User, ListChecks } from 'lucide-react'
import { MarkdownContent } from '@/components/ui/MarkdownContent' import { MarkdownContent } from '@/components/ui/MarkdownContent'
import { SuggestedFlowCard } from './SuggestedFlowCard' import { SuggestedFlowCard } from './SuggestedFlowCard'
import type { SuggestedFlow } from '@/types/copilot' import type { SuggestedFlow } from '@/types/copilot'
@@ -8,9 +8,14 @@ interface ChatMessageProps {
content: string content: string
suggestedFlows?: SuggestedFlow[] suggestedFlows?: SuggestedFlow[]
imageUrls?: string[] imageUrls?: string[]
/** When set on an assistant message, renders a leading "Next steps · N pending"
* emphasis above the bubble. Used on the current turn only — the canonical
* list of items lives in the TaskLane. */
actionCount?: number
} }
export function ChatMessage({ role, content, suggestedFlows, imageUrls }: ChatMessageProps) { export function ChatMessage({ role, content, suggestedFlows, imageUrls, actionCount }: ChatMessageProps) {
const hasActionEmphasis = role === 'assistant' && actionCount !== undefined && actionCount > 0
return ( return (
<div className={`flex gap-3 ${role === 'user' ? 'flex-row-reverse' : ''}`}> <div className={`flex gap-3 ${role === 'user' ? 'flex-row-reverse' : ''}`}>
{/* Avatar */} {/* Avatar */}
@@ -41,20 +46,32 @@ export function ChatMessage({ role, content, suggestedFlows, imageUrls }: ChatMe
</div> </div>
)} )}
{hasActionEmphasis && (
<div className="flex items-center gap-1.5 text-xs font-medium text-heading">
<ListChecks size={12} className="text-primary" />
Next steps
<span className="text-muted-foreground font-normal">
· {actionCount} pending in Tasks
</span>
</div>
)}
<div <div
className={`rounded-2xl px-4 py-3 text-[0.875rem] leading-relaxed ${ className={`rounded-xl px-4 py-3 text-sm leading-relaxed ${
role === 'user' role === 'user'
? 'bg-primary/15 text-foreground' ? 'bg-primary/15 text-foreground'
: 'bg-input text-foreground border border-border' : hasActionEmphasis
? 'bg-input text-foreground border border-hover'
: 'bg-input text-foreground border border-border'
}`} }`}
> >
<MarkdownContent content={content} className="text-[0.875rem] leading-relaxed" /> <MarkdownContent content={content} className="text-sm leading-relaxed" />
</div> </div>
{/* Suggested flows (assistant only) */} {/* Suggested flows (assistant only) */}
{role === 'assistant' && suggestedFlows && suggestedFlows.length > 0 && ( {role === 'assistant' && suggestedFlows && suggestedFlows.length > 0 && (
<div className="space-y-1.5"> <div className="space-y-1.5">
<span className="font-sans text-xs text-[0.625rem] uppercase tracking-widest text-muted-foreground"> <span className="text-[0.625rem] uppercase tracking-widest text-muted-foreground">
Related Flows Related Flows
</span> </span>
{suggestedFlows.map(flow => ( {suggestedFlows.map(flow => (

View File

@@ -85,7 +85,7 @@ export function ChatSidebar({
<div className="flex-1 overflow-y-auto py-2"> <div className="flex-1 overflow-y-auto py-2">
{pinnedChats.length > 0 && ( {pinnedChats.length > 0 && (
<div className="px-3 mb-1"> <div className="px-3 mb-1">
<span className="font-sans text-[0.625rem] uppercase tracking-widest text-muted-foreground"> <span className="text-[0.625rem] uppercase tracking-widest text-muted-foreground">
Pinned Pinned
</span> </span>
</div> </div>
@@ -159,7 +159,7 @@ export function ChatSidebarCollapsedBar({
<History size={14} /> <History size={14} />
<span>History</span> <span>History</span>
{chats.length > 0 && ( {chats.length > 0 && (
<span className="text-[10px] bg-elevated rounded-full px-1.5 py-0.5 font-medium">{chats.length}</span> <span className="text-[0.625rem] bg-elevated rounded-full px-1.5 py-0.5 font-medium">{chats.length}</span>
)} )}
</button> </button>
<div className="flex-1" /> <div className="flex-1" />
@@ -203,7 +203,7 @@ function ChatItem({
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
{confirming ? ( {confirming ? (
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="text-[0.75rem] text-danger font-medium">Delete?</span> <span className="text-xs text-danger font-medium">Delete?</span>
<button <button
onClick={e => { e.stopPropagation(); onDelete(); setConfirming(false) }} onClick={e => { e.stopPropagation(); onDelete(); setConfirming(false) }}
className="text-[0.6875rem] font-medium text-danger hover:text-danger px-1.5 py-0.5 rounded bg-danger/15 hover:bg-danger/25 transition-colors" className="text-[0.6875rem] font-medium text-danger hover:text-danger px-1.5 py-0.5 rounded bg-danger/15 hover:bg-danger/25 transition-colors"
@@ -222,12 +222,12 @@ function ChatItem({
<div className="flex items-center gap-1.5 min-w-0"> <div className="flex items-center gap-1.5 min-w-0">
<div className="text-[0.8125rem] font-medium truncate">{chat.title}</div> <div className="text-[0.8125rem] font-medium truncate">{chat.title}</div>
{chat.psa_ticket_id && ( {chat.psa_ticket_id && (
<span className="font-mono shrink-0 rounded-md bg-accent-dim px-1.5 py-0.5 text-[0.5625rem] text-accent-text"> <span className="font-mono shrink-0 rounded-md bg-accent-dim px-1.5 py-0.5 text-[0.625rem] text-accent-text">
#{chat.psa_ticket_id} #{chat.psa_ticket_id}
</span> </span>
)} )}
{(chat.status === 'escalated' || chat.status === 'requesting_escalation') && ( {(chat.status === 'escalated' || chat.status === 'requesting_escalation') && (
<span className="font-sans shrink-0 rounded-md bg-warning-dim px-1.5 py-0.5 text-[0.5625rem] uppercase tracking-wider text-warning border border-warning/20"> <span className="shrink-0 rounded-md bg-warning-dim px-1.5 py-0.5 text-[0.625rem] uppercase tracking-wider text-warning border border-warning/20">
Escalated Escalated
</span> </span>
)} )}

View File

@@ -268,7 +268,7 @@ export function ConcludeSessionModal({
)} )}
<div <div
className={cn( className={cn(
'w-6 h-6 rounded-full flex items-center justify-center text-[0.6875rem] font-sans text-xs font-medium transition-colors', 'w-6 h-6 rounded-full flex items-center justify-center text-[0.6875rem] font-medium transition-colors',
step === s step === s
? 'bg-primary text-white' ? 'bg-primary text-white'
: (i < ['select-outcome', 'add-notes', 'summary'].indexOf(step)) : (i < ['select-outcome', 'add-notes', 'summary'].indexOf(step))
@@ -280,7 +280,7 @@ export function ConcludeSessionModal({
</div> </div>
<span <span
className={cn( className={cn(
'text-xs font-sans text-xs', 'text-xs',
step === s ? 'text-foreground' : 'text-muted-foreground' step === s ? 'text-foreground' : 'text-muted-foreground'
)} )}
> >
@@ -329,7 +329,7 @@ export function ConcludeSessionModal({
<div className="space-y-4"> <div className="space-y-4">
{/* Selected outcome badge */} {/* Selected outcome badge */}
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<div className={cn('px-3 py-1.5 rounded-lg flex items-center gap-2 text-xs font-sans text-xs', selectedOutcome.bg, selectedOutcome.border, 'border')}> <div className={cn('px-3 py-1.5 rounded-lg flex items-center gap-2 text-xs', selectedOutcome.bg, selectedOutcome.border, 'border')}>
<selectedOutcome.icon size={14} className={selectedOutcome.color} /> <selectedOutcome.icon size={14} className={selectedOutcome.color} />
<span className={selectedOutcome.color}>{selectedOutcome.label}</span> <span className={selectedOutcome.color}>{selectedOutcome.label}</span>
</div> </div>
@@ -342,7 +342,7 @@ export function ConcludeSessionModal({
</div> </div>
<div> <div>
<label className="font-sans text-xs text-[0.625rem] uppercase tracking-widest text-muted-foreground block mb-2"> <label className="text-[0.625rem] uppercase tracking-widest text-muted-foreground block mb-2">
Additional Notes (optional) Additional Notes (optional)
</label> </label>
<textarea <textarea
@@ -383,7 +383,7 @@ export function ConcludeSessionModal({
<div className="space-y-4"> <div className="space-y-4">
{/* Outcome badge */} {/* Outcome badge */}
{selectedOutcome && ( {selectedOutcome && (
<div className={cn('px-3 py-1.5 rounded-lg inline-flex items-center gap-2 text-xs font-sans text-xs', selectedOutcome.bg, selectedOutcome.border, 'border')}> <div className={cn('px-3 py-1.5 rounded-lg inline-flex items-center gap-2 text-xs', selectedOutcome.bg, selectedOutcome.border, 'border')}>
<selectedOutcome.icon size={14} className={selectedOutcome.color} /> <selectedOutcome.icon size={14} className={selectedOutcome.color} />
<span className={selectedOutcome.color}>{selectedOutcome.label}</span> <span className={selectedOutcome.color}>{selectedOutcome.label}</span>
</div> </div>
@@ -396,7 +396,7 @@ export function ConcludeSessionModal({
style={{ borderColor: 'var(--color-border-default)' }} style={{ borderColor: 'var(--color-border-default)' }}
> >
<div className="flex items-center justify-between mb-3"> <div className="flex items-center justify-between mb-3">
<span className="font-sans text-xs text-[0.625rem] uppercase tracking-widest text-muted-foreground flex items-center gap-1.5"> <span className="text-[0.625rem] uppercase tracking-widest text-muted-foreground flex items-center gap-1.5">
<Sparkles size={10} className="text-primary" /> <Sparkles size={10} className="text-primary" />
Ticket Notes Ticket Notes
</span> </span>
@@ -488,7 +488,7 @@ export function ConcludeSessionModal({
style={{ borderColor: 'var(--color-border-default)' }} style={{ borderColor: 'var(--color-border-default)' }}
> >
<div className="flex items-center justify-between mb-3"> <div className="flex items-center justify-between mb-3">
<span className="font-sans text-xs text-[0.625rem] uppercase tracking-widest text-muted-foreground flex items-center gap-1.5"> <span className="text-[0.625rem] uppercase tracking-widest text-muted-foreground flex items-center gap-1.5">
<Sparkles size={10} className="text-primary" /> <Sparkles size={10} className="text-primary" />
Status Update Status Update
</span> </span>

View File

@@ -27,11 +27,11 @@ export function SuggestedFlowCard({ flow }: SuggestedFlowCardProps) {
<span className="text-[0.8125rem] font-medium text-foreground truncate"> <span className="text-[0.8125rem] font-medium text-foreground truncate">
{flow.tree_name} {flow.tree_name}
</span> </span>
<span className="font-sans text-xs text-[0.625rem] uppercase tracking-wider text-muted-foreground"> <span className="text-[0.625rem] uppercase tracking-wider text-muted-foreground">
{flow.tree_type} {flow.tree_type}
</span> </span>
</div> </div>
<p className="text-[0.75rem] text-muted-foreground mt-0.5 line-clamp-2"> <p className="text-xs text-muted-foreground mt-0.5 line-clamp-2">
{flow.relevance_snippet} {flow.relevance_snippet}
</p> </p>
</div> </div>

View File

@@ -1,7 +1,7 @@
import { useState, useEffect, useRef, useCallback } from 'react' import { useState, useEffect, useRef, useCallback } from 'react'
import { import {
Copy, Check, SkipForward, Terminal, ChevronDown, ChevronUp, Copy, Check, SkipForward, Terminal, ChevronDown, ChevronUp,
Send, Clipboard, Loader2, PanelRightClose, MessageCircleQuestion, Eye, Send, Clipboard, Loader2, PanelRightClose, Pencil, HelpCircle, Eye,
} from 'lucide-react' } from 'lucide-react'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { toast } from '@/lib/toast' import { toast } from '@/lib/toast'
@@ -31,6 +31,62 @@ interface ActionResponse {
type TaskResponse = QuestionResponse | ActionResponse type TaskResponse = QuestionResponse | ActionResponse
interface DiagnosticHelp {
what: string
lookFor: string
usefulWhen: string
}
function getDiagnosticHelp(action: ActionResponse): DiagnosticHelp {
const command = (action.command || '').toLowerCase()
if (command.includes('test-netconnection') || command.includes('ping ')) {
return {
what: action.description || 'Checks whether the target is reachable over the network.',
lookFor: 'Successful replies, low packet loss, and whether the expected port shows as open.',
usefulWhen: 'Use it when you need to separate a service problem from a basic connectivity problem.',
}
}
if (command.includes('nslookup') || command.includes('resolve-dnsname')) {
return {
what: action.description || 'Checks how DNS resolves the hostname or record.',
lookFor: 'Wrong IPs, NXDOMAIN responses, timeout errors, or different answers from different resolvers.',
usefulWhen: 'Use it when names fail but direct IP access may still work.',
}
}
if (command.includes('ipconfig') || command.includes('get-netipconfiguration')) {
return {
what: action.description || 'Shows local IP, gateway, DNS, and adapter configuration.',
lookFor: 'APIPA addresses, missing gateways, wrong DNS servers, disconnected adapters, or stale leases.',
usefulWhen: 'Use it early when the symptom may be local network configuration.',
}
}
if (command.includes('get-eventlog') || command.includes('get-winevent') || command.includes('eventlog')) {
return {
what: action.description || 'Reads Windows event logs for recent errors or warnings.',
lookFor: 'Events matching the failure time, repeated error IDs, service crashes, or permission failures.',
usefulWhen: 'Use it when the UI only shows a generic error and you need system-level evidence.',
}
}
if (command.includes('get-service') || command.includes('restart-service')) {
return {
what: action.description || 'Checks service state on the affected machine.',
lookFor: 'Stopped services, restart loops, disabled startup types, or dependency failures.',
usefulWhen: 'Use it when a feature depends on a Windows service or background agent.',
}
}
return {
what: action.description || 'Runs the diagnostic check suggested by FlowPilot.',
lookFor: 'Errors, unexpected values, failed checks, or output that differs from a known-good machine.',
usefulWhen: 'Use it when you need evidence before choosing the next troubleshooting step.',
}
}
interface TaskLaneProps { interface TaskLaneProps {
questions: QuestionItem[] questions: QuestionItem[]
actions: ActionItem[] actions: ActionItem[]
@@ -98,6 +154,7 @@ export function TaskLane({ questions, actions, sessionId, onSubmit, onClose, loa
const [showRunAll, setShowRunAll] = useState(false) const [showRunAll, setShowRunAll] = useState(false)
const [showPreview, setShowPreview] = useState(false) const [showPreview, setShowPreview] = useState(false)
const [copiedKey, setCopiedKey] = useState<string | null>(null) const [copiedKey, setCopiedKey] = useState<string | null>(null)
const [expandedHelpKey, setExpandedHelpKey] = useState<string | null>(null)
// ── Resize state ── // ── Resize state ──
const DEFAULT_WIDTH = 340 const DEFAULT_WIDTH = 340
@@ -166,22 +223,22 @@ export function TaskLane({ questions, actions, sessionId, onSubmit, onClose, loa
questions: questionsRef.current.map(q => ({ text: q.text, context: q.context })), questions: questionsRef.current.map(q => ({ text: q.text, context: q.context })),
actions: actionsRef.current.map(a => ({ label: a.label, command: a.command, description: a.description })), actions: actionsRef.current.map(a => ({ label: a.label, command: a.command, description: a.description })),
responses: tasksRef.current as unknown as Array<Record<string, unknown>>, responses: tasksRef.current as unknown as Array<Record<string, unknown>>,
}).catch(() => { /* silent best-effort save */ }) }).catch(() => { /* silent - best-effort save */ })
}, 2000) }, 2000)
return () => { if (saveTimerRef.current) clearTimeout(saveTimerRef.current) } return () => { if (saveTimerRef.current) clearTimeout(saveTimerRef.current) }
}, [sessionId, tasks]) // eslint-disable-line react-hooks/exhaustive-deps }, [sessionId, tasks])
// Reset when new tasks come in from AI response — but preserve saved state // Reset when new tasks come in from AI response — but preserve saved state
useEffect(() => { useEffect(() => {
if (sessionId) { if (sessionId) {
const saved = loadTaskState(sessionId) const saved = loadTaskState(sessionId)
if (saved && saved.length > 0) { if (saved && saved.length > 0) {
// eslint-disable-next-line react-hooks/set-state-in-effect -- intentional: syncs derived state from prop changes // eslint-disable-next-line react-hooks/set-state-in-effect -- intentional: syncs task UI from persisted session state
setTasks(saved) setTasks(saved)
return return
} }
} }
// eslint-disable-next-line react-hooks/set-state-in-effect -- intentional: syncs derived state from prop changes
setTasks([ setTasks([
...questions.map((q): QuestionResponse => ({ ...questions.map((q): QuestionResponse => ({
type: 'question', text: q.text, context: q.context, state: 'pending', value: '', type: 'question', text: q.text, context: q.context, state: 'pending', value: '',
@@ -190,12 +247,30 @@ export function TaskLane({ questions, actions, sessionId, onSubmit, onClose, loa
type: 'action', label: a.label, command: a.command, description: a.description, state: 'pending', value: '', type: 'action', label: a.label, command: a.command, description: a.description, state: 'pending', value: '',
})), })),
]) ])
}, [questions, actions]) // eslint-disable-line react-hooks/exhaustive-deps }, [questions, actions, sessionId])
const updateTask = (idx: number, updates: Partial<TaskResponse>) => { const updateTask = (idx: number, updates: Partial<TaskResponse>) => {
setTasks(prev => prev.map((t, i) => i === idx ? { ...t, ...updates } as TaskResponse : t)) setTasks(prev => prev.map((t, i) => i === idx ? { ...t, ...updates } as TaskResponse : t))
} }
// Mark `idx` done and advance focus to the next pending task. If none are
// left, focus the Send button so the engineer can fire the batch with one
// more keystroke. Powers both keyboard submit (Enter / Cmd+Enter) and the
// mouse path on the Answer / Done buttons.
const sendButtonRef = useRef<HTMLButtonElement>(null)
const submitAndAdvance = (idx: number, value: string) => {
if (!value.trim()) return
const nextIdx = tasks.findIndex((t, i) => i > idx && t.state === 'pending')
setTasks(prev => prev.map((t, i) => {
if (i === idx) return { ...t, state: 'done' } as TaskResponse
if (nextIdx !== -1 && i === nextIdx) return { ...t, state: 'active' } as TaskResponse
return t
}))
if (nextIdx === -1) {
setTimeout(() => sendButtonRef.current?.focus(), 50)
}
}
const questionTasks = tasks.filter(t => t.type === 'question') const questionTasks = tasks.filter(t => t.type === 'question')
const actionTasks = tasks.filter(t => t.type === 'action') as ActionResponse[] const actionTasks = tasks.filter(t => t.type === 'action') as ActionResponse[]
const allHandled = tasks.every(t => t.state === 'done' || t.state === 'skipped') const allHandled = tasks.every(t => t.state === 'done' || t.state === 'skipped')
@@ -293,20 +368,21 @@ export function TaskLane({ questions, actions, sessionId, onSubmit, onClose, loa
</div> </div>
)} )}
{/* Header */} {/* Header */}
<div className="px-4 py-3 border-b border-default flex items-center justify-between shrink-0" style={{ borderTop: '2px solid var(--color-accent)' }}> <div className="px-4 py-3 border-b border-default flex items-center justify-between shrink-0">
<h3 className="font-heading text-sm font-bold text-heading flex items-center gap-2"> <h3 className="font-heading text-sm font-bold text-heading flex items-center gap-2">
Tasks Tasks
<span className={cn( {allHandled ? (
'text-[10px] font-semibold px-2 py-0.5 rounded-full', <span className="flex items-center gap-1 text-[0.625rem] font-semibold uppercase tracking-wider text-success">
allHandled <Check size={10} /> Ready
? 'bg-success-dim text-success' </span>
: 'bg-accent-dim text-accent-text' ) : (
)}> <span className="text-[0.625rem] font-medium tabular-nums text-muted-foreground">
{allHandled ? '✓ Ready' : `${doneCount}/${totalCount}`} {doneCount}/{totalCount}
</span> </span>
)}
{loading && ( {loading && (
<span <span
className="flex items-center gap-1 text-[10px] font-medium text-muted-foreground" className="flex items-center gap-1 text-[0.625rem] font-medium text-muted-foreground"
title="AI is thinking" title="AI is thinking"
> >
<Loader2 size={10} className="animate-spin" /> <Loader2 size={10} className="animate-spin" />
@@ -329,7 +405,7 @@ export function TaskLane({ questions, actions, sessionId, onSubmit, onClose, loa
{questionTasks.length > 0 && ( {questionTasks.length > 0 && (
<section> <section>
<div className="pb-2"> <div className="pb-2">
<div className="flex items-center gap-2 text-[10px] font-semibold uppercase tracking-[1.2px] text-muted-foreground pl-0.5"> <div className="flex items-center gap-2 text-[0.625rem] font-semibold uppercase tracking-[1.2px] text-muted-foreground pl-0.5">
<span className="w-1.5 h-1.5 rounded-full bg-accent" /> <span className="w-1.5 h-1.5 rounded-full bg-accent" />
Questions Questions
{questionTasks.every(q => q.state === 'done' || q.state === 'skipped') && ( {questionTasks.every(q => q.state === 'done' || q.state === 'skipped') && (
@@ -344,12 +420,12 @@ export function TaskLane({ questions, actions, sessionId, onSubmit, onClose, loa
if (q.state === 'done') { if (q.state === 'done') {
return ( return (
<div key={idx} id={`task-lane-card-${idx}`} className="rounded-lg border-l-[3px] border-l-success border border-success/25 bg-success-dim/30 p-3 mb-2 cursor-pointer hover:border-success/40 transition-colors" onClick={() => updateTask(idx, { state: 'active' })}> <div key={idx} id={`task-lane-card-${idx}`} className="rounded-lg border border-default/40 p-3 mb-2 cursor-pointer hover:border-default transition-colors" onClick={() => updateTask(idx, { state: 'active' })}>
<div className="flex items-center gap-1.5"> <div className="flex items-center gap-1.5">
<Check size={12} className="text-success shrink-0" /> <Check size={12} className="text-success shrink-0" />
<span className="text-[0.8125rem] text-foreground">{q.text}</span> <span className="text-[0.8125rem] text-muted-foreground">{q.text}</span>
</div> </div>
<div className="text-[0.75rem] text-muted-foreground mt-1 pl-5 italic truncate">"{q.value}"</div> <div className="text-xs text-muted-foreground/80 mt-1 pl-5 italic truncate">"{q.value}"</div>
</div> </div>
) )
} }
@@ -359,7 +435,7 @@ export function TaskLane({ questions, actions, sessionId, onSubmit, onClose, loa
<div key={idx} id={`task-lane-card-${idx}`} className="rounded-lg border border-default/50 bg-elevated/20 p-3 mb-2 opacity-60 cursor-pointer hover:opacity-80 hover:border-default transition-all" onClick={() => updateTask(idx, { state: 'pending' })} title="Click to restore"> <div key={idx} id={`task-lane-card-${idx}`} className="rounded-lg border border-default/50 bg-elevated/20 p-3 mb-2 opacity-60 cursor-pointer hover:opacity-80 hover:border-default transition-all" onClick={() => updateTask(idx, { state: 'pending' })} title="Click to restore">
<div className="flex justify-between"> <div className="flex justify-between">
<div className="text-[0.8125rem] text-muted-foreground line-through">{q.text}</div> <div className="text-[0.8125rem] text-muted-foreground line-through">{q.text}</div>
<span className="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">Skipped</span> <span className="text-[0.625rem] font-semibold uppercase tracking-wider text-muted-foreground">Skipped</span>
</div> </div>
</div> </div>
) )
@@ -377,33 +453,47 @@ export function TaskLane({ questions, actions, sessionId, onSubmit, onClose, loa
autoFocus autoFocus
value={q.value} value={q.value}
onChange={e => updateTask(idx, { value: e.target.value })} onChange={e => updateTask(idx, { value: e.target.value })}
onKeyDown={e => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault()
submitAndAdvance(idx, q.value)
} else if (e.key === 'Escape') {
e.preventDefault()
updateTask(idx, { state: 'pending', value: '' })
}
}}
placeholder="Type your answer..." placeholder="Type your answer..."
className="w-full rounded-md border border-default bg-input px-3 py-2 text-[0.8125rem] text-heading placeholder:text-muted-foreground resize-y min-h-[48px] max-h-[150px] focus:outline-none focus:border-accent focus:ring-1 focus:ring-accent/30" className="w-full rounded-md border border-default bg-input px-3 py-2 text-[0.8125rem] text-heading placeholder:text-muted-foreground resize-y min-h-[48px] max-h-[150px] focus:outline-none focus:border-accent focus:ring-1 focus:ring-accent/30"
rows={2} rows={2}
/> />
<div className="mt-1.5 flex items-center gap-2"> <div className="mt-1.5 flex items-center justify-between gap-2">
<button <div className="flex items-center gap-2">
onClick={() => updateTask(idx, { state: 'done' })} <button
disabled={!q.value.trim()} onClick={() => submitAndAdvance(idx, q.value)}
className="flex items-center gap-1 rounded-md bg-accent px-2.5 py-1 text-[0.75rem] font-medium text-white disabled:opacity-40 hover:bg-accent-hover transition-colors" disabled={!q.value.trim()}
> className="flex items-center gap-1 rounded-md bg-accent px-2.5 py-1 text-xs font-medium text-white disabled:opacity-40 hover:bg-accent-hover transition-colors"
<Check size={11} /> Answer >
</button> <Check size={11} /> Answer
<button </button>
onClick={() => updateTask(idx, { state: 'pending', value: '' })} <button
className="text-[0.75rem] text-muted-foreground hover:text-heading" onClick={() => updateTask(idx, { state: 'pending', value: '' })}
> className="text-xs text-muted-foreground hover:text-heading"
Cancel >
</button> Cancel
</button>
</div>
<span className="text-[0.625rem] text-muted-foreground tabular-nums">
submit · newline
</span>
</div> </div>
</div> </div>
) : ( ) : (
<div className="mt-2 flex items-center gap-2"> <div className="mt-2 flex items-center gap-2">
<button <button
onClick={() => updateTask(idx, { state: 'active' })} onClick={() => updateTask(idx, { state: 'active' })}
className="flex items-center gap-1 rounded-md border border-accent/40 bg-accent-dim/30 px-2.5 py-1.5 text-[0.75rem] font-medium text-accent-text hover:bg-accent-dim/50 transition-colors" className="flex items-center gap-1 rounded-md border border-accent/40 bg-accent-dim/30 px-2.5 py-1.5 text-xs font-medium text-accent-text hover:bg-accent-dim/50 transition-colors"
> >
<MessageCircleQuestion size={11} /> Answer <Pencil size={11} /> Answer
</button> </button>
<button <button
onClick={() => updateTask(idx, { state: 'skipped' })} onClick={() => updateTask(idx, { state: 'skipped' })}
@@ -424,7 +514,7 @@ export function TaskLane({ questions, actions, sessionId, onSubmit, onClose, loa
{actionTasks.length > 0 && ( {actionTasks.length > 0 && (
<section> <section>
<div className="pb-2"> <div className="pb-2">
<div className="flex items-center gap-2 text-[10px] font-semibold uppercase tracking-[1.2px] text-muted-foreground pl-0.5"> <div className="flex items-center gap-2 text-[0.625rem] font-semibold uppercase tracking-[1.2px] text-muted-foreground pl-0.5">
<span className="w-1.5 h-1.5 rounded-full bg-accent" /> <span className="w-1.5 h-1.5 rounded-full bg-accent" />
Diagnostic Checks Diagnostic Checks
{actionTasks.every(a => a.state === 'done' || a.state === 'skipped') && ( {actionTasks.every(a => a.state === 'done' || a.state === 'skipped') && (
@@ -438,7 +528,7 @@ export function TaskLane({ questions, actions, sessionId, onSubmit, onClose, loa
<div className="mb-2"> <div className="mb-2">
<button <button
onClick={() => setShowRunAll(!showRunAll)} onClick={() => setShowRunAll(!showRunAll)}
className="flex items-center gap-1.5 text-[0.75rem] font-medium text-accent-text hover:text-accent transition-colors pl-0.5" className="flex items-center gap-1.5 text-xs font-medium text-accent-text hover:text-accent transition-colors pl-0.5"
> >
<Terminal size={12} /> <Terminal size={12} />
Run All ({commandActions.length} commands) Run All ({commandActions.length} commands)
@@ -447,16 +537,16 @@ export function TaskLane({ questions, actions, sessionId, onSubmit, onClose, loa
{showRunAll && ( {showRunAll && (
<div className="mt-2 rounded-lg border border-default bg-code p-3"> <div className="mt-2 rounded-lg border border-default bg-code p-3">
<div className="flex items-center justify-between mb-2"> <div className="flex items-center justify-between mb-2">
<span className="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">Combined script</span> <span className="text-[0.625rem] font-semibold uppercase tracking-wider text-muted-foreground">Combined script</span>
<button <button
onClick={() => void handleCopy(combinedScript)} onClick={() => void handleCopy(combinedScript)}
className="flex items-center gap-1 text-[0.75rem] text-muted-foreground hover:text-heading transition-colors" className="flex items-center gap-1 text-xs text-muted-foreground hover:text-heading transition-colors"
> >
{copiedKey === combinedScript ? <Check size={11} className="text-success" /> : <Copy size={11} />} {copiedKey === combinedScript ? <Check size={11} className="text-success" /> : <Copy size={11} />}
{copiedKey === combinedScript ? 'Copied' : 'Copy'} {copiedKey === combinedScript ? 'Copied' : 'Copy'}
</button> </button>
</div> </div>
<pre className="text-[0.75rem] font-mono text-heading whitespace-pre-wrap overflow-x-auto">{combinedScript}</pre> <pre className="text-xs font-mono text-heading whitespace-pre-wrap overflow-x-auto">{combinedScript}</pre>
</div> </div>
)} )}
</div> </div>
@@ -468,10 +558,10 @@ export function TaskLane({ questions, actions, sessionId, onSubmit, onClose, loa
if (a.state === 'done') { if (a.state === 'done') {
return ( return (
<div key={idx} id={`task-lane-card-${idx}`} className="rounded-lg border-l-[3px] border-l-success border border-success/25 bg-success-dim/30 p-3 mb-2 cursor-pointer hover:border-success/40 transition-colors" onClick={() => updateTask(idx, { state: 'active' })}> <div key={idx} id={`task-lane-card-${idx}`} className="rounded-lg border border-default/40 p-3 mb-2 cursor-pointer hover:border-default transition-colors" onClick={() => updateTask(idx, { state: 'active' })}>
<div className="flex items-center gap-1.5"> <div className="flex items-center gap-1.5">
<Check size={12} className="text-success shrink-0" /> <Check size={12} className="text-success shrink-0" />
<span className="text-[0.8125rem] font-medium text-foreground flex-1">{a.label}</span> <span className="text-[0.8125rem] text-muted-foreground flex-1">{a.label}</span>
</div> </div>
</div> </div>
) )
@@ -482,7 +572,7 @@ export function TaskLane({ questions, actions, sessionId, onSubmit, onClose, loa
<div key={idx} id={`task-lane-card-${idx}`} className="rounded-lg border border-default/50 bg-elevated/20 p-3 mb-2 opacity-60 cursor-pointer hover:opacity-80 hover:border-default transition-all" onClick={() => updateTask(idx, { state: 'pending' })} title="Click to restore"> <div key={idx} id={`task-lane-card-${idx}`} className="rounded-lg border border-default/50 bg-elevated/20 p-3 mb-2 opacity-60 cursor-pointer hover:opacity-80 hover:border-default transition-all" onClick={() => updateTask(idx, { state: 'pending' })} title="Click to restore">
<div className="flex justify-between"> <div className="flex justify-between">
<div className="text-[0.8125rem] text-muted-foreground line-through">{a.label}</div> <div className="text-[0.8125rem] text-muted-foreground line-through">{a.label}</div>
<span className="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">Skipped</span> <span className="text-[0.625rem] font-semibold uppercase tracking-wider text-muted-foreground">Skipped</span>
</div> </div>
</div> </div>
) )
@@ -490,10 +580,49 @@ export function TaskLane({ questions, actions, sessionId, onSubmit, onClose, loa
return ( return (
<div key={idx} id={`task-lane-card-${idx}`} className="rounded-lg border border-default bg-card p-3 mb-2 hover:border-hover transition-colors"> <div key={idx} id={`task-lane-card-${idx}`} className="rounded-lg border border-default bg-card p-3 mb-2 hover:border-hover transition-colors">
<div className="text-[0.8125rem] font-medium text-heading">{a.label}</div> <div className="flex items-start justify-between gap-2">
{a.description && ( <div className="min-w-0 flex-1">
<div className="text-[0.6875rem] text-muted-foreground mt-0.5 leading-relaxed">{a.description}</div> <div className="text-[0.8125rem] font-medium text-heading">{a.label}</div>
)} {a.description && (
<div className="text-[0.6875rem] text-muted-foreground mt-0.5 leading-relaxed">{a.description}</div>
)}
</div>
<button
type="button"
onClick={() => setExpandedHelpKey(expandedHelpKey === `${idx}` ? null : `${idx}`)}
className={cn(
'shrink-0 rounded-md p-1.5 text-muted-foreground transition-colors hover:bg-elevated/50 hover:text-heading',
expandedHelpKey === `${idx}` && 'bg-accent-dim text-accent-text',
)}
title="Explain this check"
aria-label="Explain this diagnostic check"
aria-expanded={expandedHelpKey === `${idx}`}
>
<HelpCircle size={13} />
</button>
</div>
{expandedHelpKey === `${idx}` && (() => {
const help = getDiagnosticHelp(a)
return (
<div className="mt-2 rounded-lg border border-info/20 bg-info-dim/20 p-2.5 text-[0.6875rem] leading-relaxed">
<div className="space-y-1.5">
<p>
<span className="font-semibold text-heading">What it checks: </span>
<span className="text-muted-foreground">{help.what}</span>
</p>
<p>
<span className="font-semibold text-heading">What to look for: </span>
<span className="text-muted-foreground">{help.lookFor}</span>
</p>
<p>
<span className="font-semibold text-heading">When to use it: </span>
<span className="text-muted-foreground">{help.usefulWhen}</span>
</p>
</div>
</div>
)
})()}
{a.command && ( {a.command && (
<div className="mt-2 flex items-center gap-2 rounded bg-code px-2.5 py-1.5"> <div className="mt-2 flex items-center gap-2 rounded bg-code px-2.5 py-1.5">
@@ -517,31 +646,45 @@ export function TaskLane({ questions, actions, sessionId, onSubmit, onClose, loa
autoFocus autoFocus
value={a.value} value={a.value}
onChange={e => updateTask(idx, { value: e.target.value })} onChange={e => updateTask(idx, { value: e.target.value })}
onKeyDown={e => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault()
submitAndAdvance(idx, a.value)
} else if (e.key === 'Escape') {
e.preventDefault()
updateTask(idx, { state: 'pending', value: '' })
}
}}
placeholder="Paste command output here..." placeholder="Paste command output here..."
className="w-full rounded-md border border-default bg-input px-3 py-2 text-[0.8125rem] text-heading placeholder:text-muted-foreground font-mono resize-y min-h-[60px] max-h-[200px] overflow-y-auto focus:outline-none focus:border-accent focus:ring-1 focus:ring-accent/30" className="w-full rounded-md border border-default bg-input px-3 py-2 text-[0.8125rem] text-heading placeholder:text-muted-foreground font-mono resize-y min-h-[60px] max-h-[200px] overflow-y-auto focus:outline-none focus:border-accent focus:ring-1 focus:ring-accent/30"
rows={3} rows={3}
/> />
<div className="mt-1.5 flex items-center gap-2"> <div className="mt-1.5 flex items-center justify-between gap-2">
<button <div className="flex items-center gap-2">
onClick={() => updateTask(idx, { state: 'done' })} <button
disabled={!a.value.trim()} onClick={() => submitAndAdvance(idx, a.value)}
className="flex items-center gap-1 rounded-md bg-accent px-2.5 py-1 text-[0.75rem] font-medium text-white disabled:opacity-40 hover:bg-accent-hover transition-colors" disabled={!a.value.trim()}
> className="flex items-center gap-1 rounded-md bg-accent px-2.5 py-1 text-xs font-medium text-white disabled:opacity-40 hover:bg-accent-hover transition-colors"
<Check size={11} /> Done >
</button> <Check size={11} /> Done
<button </button>
onClick={() => updateTask(idx, { state: 'pending', value: '' })} <button
className="text-[0.75rem] text-muted-foreground hover:text-heading" onClick={() => updateTask(idx, { state: 'pending', value: '' })}
> className="text-xs text-muted-foreground hover:text-heading"
Cancel >
</button> Cancel
</button>
</div>
<span className="text-[0.625rem] text-muted-foreground tabular-nums">
submit · newline
</span>
</div> </div>
</div> </div>
) : ( ) : (
<div className="mt-2 flex items-center gap-2"> <div className="mt-2 flex items-center gap-2">
<button <button
onClick={() => updateTask(idx, { state: 'active' })} onClick={() => updateTask(idx, { state: 'active' })}
className="flex items-center gap-1 rounded-md border border-accent/40 bg-accent-dim/30 px-2.5 py-1.5 text-[0.75rem] font-medium text-accent-text hover:bg-accent-dim/50 transition-colors" className="flex items-center gap-1 rounded-md border border-accent/40 bg-accent-dim/30 px-2.5 py-1.5 text-xs font-medium text-accent-text hover:bg-accent-dim/50 transition-colors"
> >
<Clipboard size={11} /> {a.command ? 'Paste Output' : 'Answer'} <Clipboard size={11} /> {a.command ? 'Paste Output' : 'Answer'}
</button> </button>
@@ -602,7 +745,7 @@ export function TaskLane({ questions, actions, sessionId, onSubmit, onClose, loa
<div className="mb-2"> <div className="mb-2">
<button <button
onClick={() => setShowPreview(!showPreview)} onClick={() => setShowPreview(!showPreview)}
className="flex items-center gap-1.5 text-[0.75rem] font-medium text-muted-foreground hover:text-heading transition-colors mb-1" className="flex items-center gap-1.5 text-xs font-medium text-muted-foreground hover:text-heading transition-colors mb-1"
> >
<Eye size={12} /> <Eye size={12} />
Preview ({handledCount}/{totalCount} done) Preview ({handledCount}/{totalCount} done)
@@ -616,6 +759,7 @@ export function TaskLane({ questions, actions, sessionId, onSubmit, onClose, loa
</div> </div>
)} )}
<button <button
ref={sendButtonRef}
onClick={handleSubmit} onClick={handleSubmit}
disabled={!anyHandled || loading || submitting} disabled={!anyHandled || loading || submitting}
className={cn( className={cn(

View File

@@ -296,7 +296,7 @@ export function CommandPalette({ open, onClose }: CommandPaletteProps) {
} }
return result return result
}, [query, searchFlows, searchSessions, searchAISessions, user]) }, [query, searchFlows, searchSessions, searchAISessions, user, onPilotSession])
// Flatten all items for keyboard navigation // Flatten all items for keyboard navigation
const flatItems: PaletteItem[] = builtGroups.flatMap(g => g.items) const flatItems: PaletteItem[] = builtGroups.flatMap(g => g.items)
@@ -401,6 +401,7 @@ export function CommandPalette({ open, onClose }: CommandPaletteProps) {
return ( return (
<button <button
key={item.id} key={item.id}
data-testid={item.id === 'flowpilot' ? 'command-palette-flowpilot' : undefined}
onClick={() => handleSelect(item)} onClick={() => handleSelect(item)}
onMouseEnter={() => setSelectedIndex(itemGlobalIdx)} onMouseEnter={() => setSelectedIndex(itemGlobalIdx)}
className={cn( className={cn(

View File

@@ -1,4 +1,4 @@
import { useState, useEffect, useRef } from 'react' import { useCallback, useState, useEffect, useRef } from 'react'
import { FolderPlus, Check, Plus } from 'lucide-react' import { FolderPlus, Check, Plus } from 'lucide-react'
import { foldersApi } from '@/api/folders' import { foldersApi } from '@/api/folders'
import type { FolderListItem } from '@/types' import type { FolderListItem } from '@/types'
@@ -16,26 +16,7 @@ export function AddToFolderMenu({ treeId, onFolderCreated }: AddToFolderMenuProp
const [isLoading, setIsLoading] = useState(false) const [isLoading, setIsLoading] = useState(false)
const menuRef = useRef<HTMLDivElement>(null) const menuRef = useRef<HTMLDivElement>(null)
useEffect(() => { const loadFoldersAndAssignments = useCallback(async () => {
if (isOpen) {
loadFoldersAndAssignments()
}
}, [isOpen, treeId])
// Close on outside click
useEffect(() => {
const handleClickOutside = (e: MouseEvent) => {
if (menuRef.current && !menuRef.current.contains(e.target as Node)) {
setIsOpen(false)
}
}
if (isOpen) {
document.addEventListener('mousedown', handleClickOutside)
}
return () => document.removeEventListener('mousedown', handleClickOutside)
}, [isOpen])
const loadFoldersAndAssignments = async () => {
setIsLoading(true) setIsLoading(true)
try { try {
const foldersData = await foldersApi.list() const foldersData = await foldersApi.list()
@@ -59,7 +40,26 @@ export function AddToFolderMenu({ treeId, onFolderCreated }: AddToFolderMenuProp
} finally { } finally {
setIsLoading(false) setIsLoading(false)
} }
} }, [treeId])
useEffect(() => {
if (isOpen) {
loadFoldersAndAssignments()
}
}, [isOpen, loadFoldersAndAssignments])
// Close on outside click
useEffect(() => {
const handleClickOutside = (e: MouseEvent) => {
if (menuRef.current && !menuRef.current.contains(e.target as Node)) {
setIsOpen(false)
}
}
if (isOpen) {
document.addEventListener('mousedown', handleClickOutside)
}
return () => document.removeEventListener('mousedown', handleClickOutside)
}, [isOpen])
const toggleFolder = async (folderId: string) => { const toggleFolder = async (folderId: string) => {
try { try {

View File

@@ -56,6 +56,14 @@ function getIndentedName(folders: FolderListItem[], folderId: string): string {
return indent + (depth > 1 ? '└ ' : '') + (folder?.name || '') return indent + (depth > 1 ? '└ ' : '') + (folder?.name || '')
} }
// Get path string for sorting
function getPath(allFolders: FolderListItem[], folderId: string): string {
const f = allFolders.find((x) => x.id === folderId)
if (!f) return ''
if (!f.parent_id) return f.name
return getPath(allFolders, f.parent_id) + '/' + f.name
}
export function FolderEditModal({ export function FolderEditModal({
folder, folder,
parentId: initialParentId, parentId: initialParentId,
@@ -110,14 +118,6 @@ export function FolderEditModal({
}) })
}, [folder, folders]) }, [folder, folders])
// Get path string for sorting
function getPath(allFolders: FolderListItem[], folderId: string): string {
const f = allFolders.find((x) => x.id === folderId)
if (!f) return ''
if (!f.parent_id) return f.name
return getPath(allFolders, f.parent_id) + '/' + f.name
}
useEffect(() => { useEffect(() => {
if (folder) { if (folder) {
setName(folder.name) setName(folder.name)

View File

@@ -1,4 +1,4 @@
import { useState, useEffect } from 'react' import { useCallback, useState, useEffect } from 'react'
import { X, Copy, Check, Link2, Users, Lock, Globe } from 'lucide-react' import { X, Copy, Check, Link2, Users, Lock, Globe } from 'lucide-react'
import type { TreeListItem, TreeShare, TreeVisibility } from '@/types' import type { TreeListItem, TreeShare, TreeVisibility } from '@/types'
import { treesApi } from '@/api/trees' import { treesApi } from '@/api/trees'
@@ -20,16 +20,7 @@ export function ShareTreeModal({ tree, isOpen, onClose }: ShareTreeModalProps) {
const [allowForking, setAllowForking] = useState(true) const [allowForking, setAllowForking] = useState(true)
const [visibility, setVisibility] = useState<TreeVisibility>('private') const [visibility, setVisibility] = useState<TreeVisibility>('private')
useEffect(() => { const loadShares = useCallback(async () => {
if (isOpen) {
loadShares()
// Reset state
setCopied(false)
setAllowForking(true)
}
}, [isOpen, tree.id])
const loadShares = async () => {
try { try {
const sharesData = await treesApi.listShares(tree.id) const sharesData = await treesApi.listShares(tree.id)
setShares(sharesData) setShares(sharesData)
@@ -40,7 +31,16 @@ export function ShareTreeModal({ tree, isOpen, onClose }: ShareTreeModalProps) {
} catch (err) { } catch (err) {
console.error('Failed to load shares:', err) console.error('Failed to load shares:', err)
} }
} }, [tree.id])
useEffect(() => {
if (isOpen) {
loadShares()
// Reset state
setCopied(false)
setAllowForking(true)
}
}, [isOpen, loadShares])
const handleGenerateLink = async () => { const handleGenerateLink = async () => {
setIsGenerating(true) setIsGenerating(true)

View File

@@ -57,7 +57,7 @@ function TabButton({
aria-selected={active} aria-selected={active}
onClick={onClick} onClick={onClick}
className={cn( className={cn(
'relative px-3 py-[7px] text-[12.5px] font-medium rounded-t-md transition-colors', 'relative px-3 py-[7px] text-xs font-medium rounded-t-md transition-colors',
'border-b-2 -mb-px', 'border-b-2 -mb-px',
active active
? 'text-heading border-accent bg-bg-page' ? 'text-heading border-accent bg-bg-page'

View File

@@ -54,27 +54,24 @@ export function ProposalBanner(props: ProposalBannerProps) {
function ProposedBanner({ fix, onApply, onDismiss, onToggleCollapsed }: ProposalBannerProps) { function ProposedBanner({ fix, onApply, onDismiss, onToggleCollapsed }: ProposalBannerProps) {
return ( return (
<div className="relative border-t border-warning/30 bg-gradient-to-b from-warning-dim/40 to-warning-dim/20 px-5 py-3 animate-slide-up"> <div className="border-t border-warning/30 bg-card px-5 py-3 animate-slide-up">
<div className="absolute left-0 top-0 bottom-0 w-[3px] bg-warning" />
<div className="flex items-start gap-3"> <div className="flex items-start gap-3">
<div className="shrink-0 mt-0.5 w-7 h-7 rounded-md border border-warning/30 bg-warning-dim flex items-center justify-center text-warning"> <Sparkles size={16} className="text-warning shrink-0 mt-1" />
<Sparkles size={15} />
</div>
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<div className="flex items-center gap-2 font-heading text-[10px] font-semibold uppercase tracking-[1.2px] text-warning"> <div className="flex items-center gap-2 font-heading text-[0.625rem] font-semibold uppercase tracking-[1.2px] text-warning">
<span>Suggested Fix</span> <span>Suggested Fix</span>
<span className="tabular-nums px-2 py-[2px] rounded-full bg-warning/20 text-warning text-[10.5px] font-bold"> <span className="tabular-nums px-2 py-[2px] rounded-full bg-warning/20 text-warning text-[0.625rem] font-bold">
{fix.confidence_pct}% confidence {fix.confidence_pct}% confidence
</span> </span>
</div> </div>
<div className="mt-0.5 text-[14px] font-semibold text-heading leading-snug"> <div className="mt-0.5 text-sm font-semibold text-heading leading-snug">
{fix.title} {fix.title}
</div> </div>
<div className="mt-1 text-[12.5px] text-muted-foreground leading-relaxed"> <div className="mt-1 text-xs text-muted-foreground leading-relaxed">
{fix.description} {fix.description}
</div> </div>
{fix.script_template_id && ( {fix.script_template_id && (
<div className="mt-1.5 inline-flex items-center gap-1.5 text-[11.5px] text-success"> <div className="mt-1.5 inline-flex items-center gap-1.5 text-[0.6875rem] text-success">
<Check size={11} /> <Check size={11} />
Matches an existing Script Library template one-click apply Matches an existing Script Library template one-click apply
</div> </div>
@@ -92,13 +89,13 @@ function ProposedBanner({ fix, onApply, onDismiss, onToggleCollapsed }: Proposal
)} )}
<button <button
onClick={onDismiss} onClick={onDismiss}
className="px-2.5 py-1.5 rounded text-[12.5px] text-muted-foreground hover:bg-white/[0.08] hover:text-primary" className="px-2.5 py-1.5 rounded text-xs text-muted-foreground hover:bg-white/[0.08] hover:text-primary"
> >
Dismiss Dismiss
</button> </button>
<button <button
onClick={onApply} onClick={onApply}
className="px-3.5 py-[9px] rounded-lg bg-warning text-[#1a1200] font-semibold text-[12.5px] hover:brightness-110 inline-flex items-center gap-1.5" className="px-3.5 py-[9px] rounded-lg bg-warning text-[#1a1200] font-semibold text-xs hover:brightness-110 inline-flex items-center gap-1.5"
> >
Apply fix Apply fix
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round"><polyline points="9 18 15 12 9 6" /></svg> <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round"><polyline points="9 18 15 12 9 6" /></svg>
@@ -116,27 +113,23 @@ function VerifyingBanner({ fix, onOutcome }: ProposalBannerProps) {
: 'Applied' : 'Applied'
return ( return (
<div className="relative border-t border-warning/30 bg-gradient-to-b from-warning-dim/40 to-warning-dim/20 px-5 py-3 animate-slide-up"> <div className="border-t border-warning/30 bg-card px-5 py-3 animate-slide-up">
<div className="absolute left-0 top-0 bottom-0 w-[3px] bg-warning" />
<div className="flex items-start gap-3"> <div className="flex items-start gap-3">
<div className="relative shrink-0 mt-0.5 w-7 h-7 rounded-md border border-warning/30 bg-warning-dim flex items-center justify-center text-warning"> <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="text-warning shrink-0 mt-1">
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"> <circle cx="12" cy="12" r="10" />
<circle cx="12" cy="12" r="10" /> <polyline points="12 6 12 12 16 14" />
<polyline points="12 6 12 12 16 14" /> </svg>
</svg>
<span className="absolute inset-[-3px] rounded-lg animate-pulse-amber pointer-events-none" />
</div>
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<div className="flex items-center gap-2 font-heading text-[10px] font-semibold uppercase tracking-[1.2px] text-warning"> <div className="flex items-center gap-2 font-heading text-[0.625rem] font-semibold uppercase tracking-[1.2px] text-warning">
<span>Verifying</span> <span>Verifying</span>
<span className="px-2 py-[2px] rounded-full bg-warning/20 text-warning text-[10.5px] font-bold normal-case tracking-normal"> <span className="px-2 py-[2px] rounded-full bg-warning/20 text-warning text-[0.625rem] font-bold normal-case tracking-normal">
{appliedLabel} {appliedLabel}
</span> </span>
</div> </div>
<div className="mt-0.5 text-[14px] font-semibold text-heading leading-snug"> <div className="mt-0.5 text-sm font-semibold text-heading leading-snug">
Did "{fix.title}" work? Did "{fix.title}" work?
</div> </div>
<div className="mt-1 text-[12.5px] text-muted-foreground leading-relaxed"> <div className="mt-1 text-xs text-muted-foreground leading-relaxed">
Mark the outcome so the AI can either close the session with this as the resolution, or propose something else. Mark the outcome so the AI can either close the session with this as the resolution, or propose something else.
</div> </div>
</div> </div>
@@ -159,7 +152,7 @@ function VerifyingBanner({ fix, onOutcome }: ProposalBannerProps) {
const notes = window.prompt('What did you run / skip?') const notes = window.prompt('What did you run / skip?')
if (notes && notes.trim()) onOutcome('applied_partial', notes.trim()) if (notes && notes.trim()) onOutcome('applied_partial', notes.trim())
}} }}
className="w-full text-left px-3 py-2 text-[12.5px] hover:bg-elevated text-primary" className="w-full text-left px-3 py-2 text-xs hover:bg-elevated text-primary"
> >
Mark partial Mark partial
</button> </button>
@@ -169,7 +162,7 @@ function VerifyingBanner({ fix, onOutcome }: ProposalBannerProps) {
const reason = window.prompt('What are you waiting on? (e.g. "client power-cycling router")') const reason = window.prompt('What are you waiting on? (e.g. "client power-cycling router")')
if (reason && reason.trim()) onOutcome('applied_pending', reason.trim()) if (reason && reason.trim()) onOutcome('applied_pending', reason.trim())
}} }}
className="w-full text-left px-3 py-2 text-[12.5px] hover:bg-elevated text-primary inline-flex items-center gap-2" className="w-full text-left px-3 py-2 text-xs hover:bg-elevated text-primary inline-flex items-center gap-2"
> >
<Clock3 size={12} className="text-info" /> <Clock3 size={12} className="text-info" />
Waiting to verify Waiting to verify
@@ -181,14 +174,14 @@ function VerifyingBanner({ fix, onOutcome }: ProposalBannerProps) {
const reason = window.prompt("Why didn't it work? (optional)") const reason = window.prompt("Why didn't it work? (optional)")
onOutcome('applied_failed', reason?.trim() || undefined) onOutcome('applied_failed', reason?.trim() || undefined)
}} }}
className="px-3 py-[9px] rounded-lg border border-danger/30 text-danger text-[12.5px] font-medium hover:bg-danger-dim hover:border-danger inline-flex items-center gap-1.5" className="px-3 py-[9px] rounded-lg border border-danger/30 text-danger text-xs font-medium hover:bg-danger-dim hover:border-danger inline-flex items-center gap-1.5"
> >
<X size={12} strokeWidth={2.5} /> <X size={12} strokeWidth={2.5} />
Didn't work Didn't work
</button> </button>
<button <button
onClick={() => onOutcome('applied_success')} onClick={() => onOutcome('applied_success')}
className="px-3 py-[9px] rounded-lg bg-success text-[#0a1a12] font-semibold text-[12.5px] hover:brightness-110 inline-flex items-center gap-1.5" className="px-3 py-[9px] rounded-lg bg-success text-[#0a1a12] font-semibold text-xs hover:brightness-110 inline-flex items-center gap-1.5"
> >
<Check size={12} strokeWidth={2.5} /> <Check size={12} strokeWidth={2.5} />
It worked It worked
@@ -209,25 +202,22 @@ function formatRelativeMinutes(iso: string): string {
function PartialBanner({ fix, onOutcome, onApply }: ProposalBannerProps) { function PartialBanner({ fix, onOutcome, onApply }: ProposalBannerProps) {
return ( return (
<div className="relative border-t border-info/30 bg-gradient-to-b from-info-dim/40 to-info-dim/20 px-5 py-3 animate-slide-up"> <div className="border-t border-info/30 bg-card px-5 py-3 animate-slide-up">
<div className="absolute left-0 top-0 bottom-0 w-[3px] bg-info" />
<div className="flex items-start gap-3"> <div className="flex items-start gap-3">
<div className="shrink-0 mt-0.5 w-7 h-7 rounded-md border border-info/30 bg-info-dim flex items-center justify-center text-info"> <Info size={16} className="text-info shrink-0 mt-1" />
<Info size={15} />
</div>
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<div className="flex items-center gap-2 font-heading text-[10px] font-semibold uppercase tracking-[1.2px] text-info"> <div className="flex items-center gap-2 font-heading text-[0.625rem] font-semibold uppercase tracking-[1.2px] text-info">
<span>Partially applied</span> <span>Partially applied</span>
<span className="px-2 py-[2px] rounded-full bg-info/20 text-info text-[10.5px] font-bold normal-case tracking-normal"> <span className="px-2 py-[2px] rounded-full bg-info/20 text-info text-[0.625rem] font-bold normal-case tracking-normal">
Parked Parked
</span> </span>
</div> </div>
<div className="mt-0.5 text-[14px] font-semibold text-heading leading-snug"> <div className="mt-0.5 text-sm font-semibold text-heading leading-snug">
{fix.title} {fix.title}
</div> </div>
{fix.partial_notes && ( {fix.partial_notes && (
<div className="mt-1.5 flex items-center gap-2 px-2.5 py-1.5 rounded-md bg-info/[0.08] border border-info/30 text-[12px] italic text-primary"> <div className="mt-1.5 flex items-center gap-2 px-2.5 py-1.5 rounded-md bg-info/[0.08] border border-info/30 text-xs italic text-primary">
<span className="not-italic font-bold text-info text-[10.5px] uppercase tracking-[0.6px]">Note</span> <span className="not-italic font-bold text-info text-[0.625rem] uppercase tracking-[0.6px]">Note</span>
<span>{fix.partial_notes}</span> <span>{fix.partial_notes}</span>
</div> </div>
)} )}
@@ -238,19 +228,19 @@ function PartialBanner({ fix, onOutcome, onApply }: ProposalBannerProps) {
const reason = window.prompt("Why didn't it work? (optional)") const reason = window.prompt("Why didn't it work? (optional)")
onOutcome('applied_failed', reason?.trim() || undefined) onOutcome('applied_failed', reason?.trim() || undefined)
}} }}
className="px-3 py-[9px] rounded-lg border border-danger/30 text-danger text-[12.5px] font-medium hover:bg-danger-dim hover:border-danger" className="px-3 py-[9px] rounded-lg border border-danger/30 text-danger text-xs font-medium hover:bg-danger-dim hover:border-danger"
> >
Didn't work Didn't work
</button> </button>
<button <button
onClick={onApply} onClick={onApply}
className="px-3 py-[9px] rounded-lg bg-card border border-white/10 text-primary text-[12.5px] font-medium hover:bg-elevated" className="px-3 py-[9px] rounded-lg bg-card border border-white/10 text-primary text-xs font-medium hover:bg-elevated"
> >
Finish it Finish it
</button> </button>
<button <button
onClick={() => onOutcome('applied_success')} onClick={() => onOutcome('applied_success')}
className="px-3 py-[9px] rounded-lg bg-success text-[#0a1a12] font-semibold text-[12.5px] hover:brightness-110" className="px-3 py-[9px] rounded-lg bg-success text-[#0a1a12] font-semibold text-xs hover:brightness-110"
> >
It worked It worked
</button> </button>
@@ -262,25 +252,22 @@ function PartialBanner({ fix, onOutcome, onApply }: ProposalBannerProps) {
function PendingBanner({ fix, onOutcome, onDismiss }: ProposalBannerProps) { function PendingBanner({ fix, onOutcome, onDismiss }: ProposalBannerProps) {
return ( return (
<div className="relative border-t border-info/30 bg-gradient-to-b from-info-dim/40 to-info-dim/20 px-5 py-3 animate-slide-up"> <div className="border-t border-info/30 bg-card px-5 py-3 animate-slide-up">
<div className="absolute left-0 top-0 bottom-0 w-[3px] bg-info" />
<div className="flex items-start gap-3"> <div className="flex items-start gap-3">
<div className="shrink-0 mt-0.5 w-7 h-7 rounded-md border border-info/30 bg-info-dim flex items-center justify-center text-info"> <Clock3 size={16} className="text-info shrink-0 mt-1" />
<Clock3 size={15} />
</div>
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<div className="flex items-center gap-2 font-heading text-[10px] font-semibold uppercase tracking-[1.2px] text-info"> <div className="flex items-center gap-2 font-heading text-[0.625rem] font-semibold uppercase tracking-[1.2px] text-info">
<span>Awaiting verification</span> <span>Awaiting verification</span>
<span className="px-2 py-[2px] rounded-full bg-info/20 text-info text-[10.5px] font-bold normal-case tracking-normal"> <span className="px-2 py-[2px] rounded-full bg-info/20 text-info text-[0.625rem] font-bold normal-case tracking-normal">
Parked Parked
</span> </span>
</div> </div>
<div className="mt-0.5 text-[14px] font-semibold text-heading leading-snug"> <div className="mt-0.5 text-sm font-semibold text-heading leading-snug">
{fix.title} {fix.title}
</div> </div>
{fix.pending_reason && ( {fix.pending_reason && (
<div className="mt-1.5 flex items-center gap-2 px-2.5 py-1.5 rounded-md bg-info/[0.08] border border-info/30 text-[12px] italic text-primary"> <div className="mt-1.5 flex items-center gap-2 px-2.5 py-1.5 rounded-md bg-info/[0.08] border border-info/30 text-xs italic text-primary">
<span className="not-italic font-bold text-info text-[10.5px] uppercase tracking-[0.6px]">Waiting on</span> <span className="not-italic font-bold text-info text-[0.625rem] uppercase tracking-[0.6px]">Waiting on</span>
<span>{fix.pending_reason}</span> <span>{fix.pending_reason}</span>
</div> </div>
)} )}
@@ -288,7 +275,7 @@ function PendingBanner({ fix, onOutcome, onDismiss }: ProposalBannerProps) {
<div className="flex items-center gap-2 shrink-0 pt-0.5"> <div className="flex items-center gap-2 shrink-0 pt-0.5">
<button <button
onClick={onDismiss} onClick={onDismiss}
className="px-3 py-[9px] rounded-lg text-muted-foreground text-[12.5px] hover:bg-white/[0.08] hover:text-primary" className="px-3 py-[9px] rounded-lg text-muted-foreground text-xs hover:bg-white/[0.08] hover:text-primary"
> >
Dismiss Dismiss
</button> </button>
@@ -300,7 +287,7 @@ function PendingBanner({ fix, onOutcome, onDismiss }: ProposalBannerProps) {
) )
if (reason && reason.trim()) onOutcome('applied_pending', reason.trim()) if (reason && reason.trim()) onOutcome('applied_pending', reason.trim())
}} }}
className="px-3 py-[9px] rounded-lg text-muted-foreground text-[12.5px] hover:bg-white/[0.08] hover:text-primary" className="px-3 py-[9px] rounded-lg text-muted-foreground text-xs hover:bg-white/[0.08] hover:text-primary"
> >
Update reason Update reason
</button> </button>
@@ -309,13 +296,13 @@ function PendingBanner({ fix, onOutcome, onDismiss }: ProposalBannerProps) {
const reason = window.prompt("Why didn't it work? (optional)") const reason = window.prompt("Why didn't it work? (optional)")
onOutcome('applied_failed', reason?.trim() || undefined) onOutcome('applied_failed', reason?.trim() || undefined)
}} }}
className="px-3 py-[9px] rounded-lg border border-danger/30 text-danger text-[12.5px] font-medium hover:bg-danger-dim hover:border-danger" className="px-3 py-[9px] rounded-lg border border-danger/30 text-danger text-xs font-medium hover:bg-danger-dim hover:border-danger"
> >
Didn't work Didn't work
</button> </button>
<button <button
onClick={() => onOutcome('applied_success')} onClick={() => onOutcome('applied_success')}
className="px-3 py-[9px] rounded-lg bg-success text-[#0a1a12] font-semibold text-[12.5px] hover:brightness-110 inline-flex items-center gap-1.5" className="px-3 py-[9px] rounded-lg bg-success text-[#0a1a12] font-semibold text-xs hover:brightness-110 inline-flex items-center gap-1.5"
> >
<Check size={12} strokeWidth={2.5} /> <Check size={12} strokeWidth={2.5} />
It worked It worked
@@ -339,37 +326,34 @@ function AIConfirmingBanner({ fix, onAcceptAIProposal, onRejectAIProposal }: Pro
: 'was partially applied' : 'was partially applied'
return ( return (
<div className="relative border-t border-accent/30 bg-gradient-to-b from-accent-dim/40 to-accent-dim/20 px-5 py-3 animate-slide-up"> <div className="border-t border-accent/30 bg-card px-5 py-3 animate-slide-up">
<div className="absolute left-0 top-0 bottom-0 w-[3px] bg-accent" />
<div className="flex items-start gap-3"> <div className="flex items-start gap-3">
<div className="shrink-0 mt-0.5 w-7 h-7 rounded-md border border-accent/30 bg-accent-dim flex items-center justify-center text-accent"> <Sparkles size={16} className="text-accent shrink-0 mt-1" />
<Sparkles size={15} />
</div>
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<div className="flex items-center gap-2 font-heading text-[10px] font-semibold uppercase tracking-[1.2px] text-accent"> <div className="flex items-center gap-2 font-heading text-[0.625rem] font-semibold uppercase tracking-[1.2px] text-accent">
<span>AI detected outcome</span> <span>AI detected outcome</span>
<span className="px-2 py-[2px] rounded-full bg-accent/20 text-accent text-[10.5px] font-bold normal-case tracking-normal"> <span className="px-2 py-[2px] rounded-full bg-accent/20 text-accent text-[0.625rem] font-bold normal-case tracking-normal">
{isSuccess ? 'Success' : isFailure ? 'Failure' : 'Partial'} {isSuccess ? 'Success' : isFailure ? 'Failure' : 'Partial'}
</span> </span>
</div> </div>
<div className="mt-0.5 text-[14px] font-semibold text-heading leading-snug"> <div className="mt-0.5 text-sm font-semibold text-heading leading-snug">
AI thinks the fix {headlineVerb} confirm? AI thinks the fix {headlineVerb} confirm?
</div> </div>
<div className="mt-1 text-[12.5px] text-muted-foreground leading-relaxed"> <div className="mt-1 text-xs text-muted-foreground leading-relaxed">
{proposal.reason || 'Based on the recent chat. One click either confirms or corrects.'} {proposal.reason || 'Based on the recent chat. One click either confirms or corrects.'}
</div> </div>
</div> </div>
<div className="flex items-center gap-2 shrink-0 pt-0.5"> <div className="flex items-center gap-2 shrink-0 pt-0.5">
<button <button
onClick={onRejectAIProposal} onClick={onRejectAIProposal}
className="px-3 py-[9px] rounded-lg text-muted-foreground text-[12.5px] hover:bg-white/[0.08] hover:text-primary" className="px-3 py-[9px] rounded-lg text-muted-foreground text-xs hover:bg-white/[0.08] hover:text-primary"
> >
Not yet Not yet
</button> </button>
<button <button
onClick={onAcceptAIProposal} onClick={onAcceptAIProposal}
className={cn( className={cn(
'px-3 py-[9px] rounded-lg font-semibold text-[12.5px] inline-flex items-center gap-1.5 hover:brightness-110', 'px-3 py-[9px] rounded-lg font-semibold text-xs inline-flex items-center gap-1.5 hover:brightness-110',
isSuccess isSuccess
? 'bg-success text-[#0a1a12]' ? 'bg-success text-[#0a1a12]'
: 'bg-danger text-[#180808]', : 'bg-danger text-[#180808]',
@@ -386,14 +370,13 @@ function AIConfirmingBanner({ fix, onAcceptAIProposal, onRejectAIProposal }: Pro
function NudgeBanner({ fix, onOutcome, onSilenceNudge }: ProposalBannerProps) { function NudgeBanner({ fix, onOutcome, onSilenceNudge }: ProposalBannerProps) {
return ( return (
<div className="relative border-t border-warning/30 bg-warning-dim/60 px-5 py-2 flex items-center gap-3"> <div className="border-t border-warning/30 bg-card px-5 py-2 flex items-center gap-3">
<div className="absolute left-0 top-0 bottom-0 w-[3px] bg-warning" />
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="text-warning shrink-0"> <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="text-warning shrink-0">
<circle cx="12" cy="12" r="10" /> <circle cx="12" cy="12" r="10" />
<path d="M12 8v4" /> <path d="M12 8v4" />
<path d="M12 16h.01" /> <path d="M12 16h.01" />
</svg> </svg>
<span className="flex-1 text-[12.5px] text-primary"> <span className="flex-1 text-xs text-primary">
Did <strong className="text-heading">"{fix.title}"</strong> work? Did <strong className="text-heading">"{fix.title}"</strong> work?
</span> </span>
<button <button
@@ -407,7 +390,7 @@ function NudgeBanner({ fix, onOutcome, onSilenceNudge }: ProposalBannerProps) {
onSilenceNudge() onSilenceNudge()
} }
}} }}
className="px-2.5 py-1 rounded text-[12px] text-muted-foreground hover:bg-white/[0.08] hover:text-primary inline-flex items-center gap-1" className="px-2.5 py-1 rounded text-xs text-muted-foreground hover:bg-white/[0.08] hover:text-primary inline-flex items-center gap-1"
> >
<Clock3 size={11} /> <Clock3 size={11} />
Still checking Still checking
@@ -417,13 +400,13 @@ function NudgeBanner({ fix, onOutcome, onSilenceNudge }: ProposalBannerProps) {
const reason = window.prompt("Why didn't it work? (optional)") const reason = window.prompt("Why didn't it work? (optional)")
onOutcome('applied_failed', reason?.trim() || undefined) onOutcome('applied_failed', reason?.trim() || undefined)
}} }}
className="px-2.5 py-1 rounded border border-danger/30 text-danger text-[12px] hover:bg-danger-dim" className="px-2.5 py-1 rounded border border-danger/30 text-danger text-xs hover:bg-danger-dim"
> >
No No
</button> </button>
<button <button
onClick={() => onOutcome('applied_success')} onClick={() => onOutcome('applied_success')}
className="px-2.5 py-1 rounded bg-success text-[#0a1a12] font-semibold text-[12px] hover:brightness-110" className="px-2.5 py-1 rounded bg-success text-[#0a1a12] font-semibold text-xs hover:brightness-110"
> >
Yes Yes
</button> </button>
@@ -435,15 +418,14 @@ function CollapsedBanner({ fix, onToggleCollapsed }: ProposalBannerProps) {
return ( return (
<button <button
onClick={onToggleCollapsed} onClick={onToggleCollapsed}
className="relative w-full border-t border-warning/30 bg-warning-dim/40 px-5 py-2 flex items-center gap-2.5 hover:bg-warning-dim/60 transition-colors text-left" className="w-full border-t border-warning/30 bg-card px-5 py-2 flex items-center gap-2.5 hover:bg-[var(--color-bg-card-hover)] transition-colors text-left"
> >
<div className="absolute left-0 top-0 bottom-0 w-[3px] bg-warning" />
<Sparkles size={12} className="text-warning shrink-0" /> <Sparkles size={12} className="text-warning shrink-0" />
<span className="flex-1 text-[12px] font-medium text-heading truncate">{fix.title}</span> <span className="flex-1 text-xs font-medium text-heading truncate">{fix.title}</span>
<span className="px-1.5 py-[1px] rounded-full bg-warning/20 text-warning text-[10.5px] font-bold tabular-nums"> <span className="px-1.5 py-[1px] rounded-full bg-warning/20 text-warning text-[0.625rem] font-bold tabular-nums">
{fix.confidence_pct}% {fix.confidence_pct}%
</span> </span>
<span className="text-muted-foreground text-[11px]"> expand</span> <span className="text-muted-foreground text-[0.6875rem]"> expand</span>
</button> </button>
) )
} }

View File

@@ -80,10 +80,27 @@ function tokenize(body: string, highlightValues: Record<string, string> | undefi
while (cursor < seg.text.length) { while (cursor < seg.text.length) {
let matched: { key: string; value: string } | null = null let matched: { key: string; value: string } | null = null
for (const [key, value] of valueEntries) { for (const [key, value] of valueEntries) {
if (seg.text.startsWith(value, cursor)) { if (!seg.text.startsWith(value, cursor)) continue
matched = { key, value } // Word-boundary guard: a single-char value like "D" (drive letter)
break // would otherwise light up every capital D in identifiers like
} // `Get-ADUser`. We only require a boundary on a side of the value
// that itself starts/ends with a word char, so values that begin or
// end in punctuation (e.g. "D:\\Folder") still match cleanly.
const valueStartsWithWordChar = /^\w/.test(value)
const valueEndsWithWordChar = /\w$/.test(value)
const before = cursor > 0 ? seg.text[cursor - 1] : undefined
const after = cursor + value.length < seg.text.length
? seg.text[cursor + value.length]
: undefined
const startBounded = !valueStartsWithWordChar
|| before === undefined
|| !/\w/.test(before)
const endBounded = !valueEndsWithWordChar
|| after === undefined
|| !/\w/.test(after)
if (!startBounded || !endBounded) continue
matched = { key, value }
break
} }
if (matched) { if (matched) {
flushPending() flushPending()

View File

@@ -39,7 +39,7 @@ export function AddNoteButton({ onAdd }: AddNoteButtonProps) {
return ( return (
<button <button
onClick={() => setOpen(true)} onClick={() => setOpen(true)}
className="flex items-center gap-1.5 text-[0.75rem] text-muted-foreground hover:text-accent-text transition-colors pl-1 mt-1" className="flex items-center gap-1.5 text-xs text-muted-foreground hover:text-accent-text transition-colors pl-1 mt-1"
> >
<Plus size={12} /> <Plus size={12} />
Add a note Add a note
@@ -62,20 +62,20 @@ export function AddNoteButton({ onAdd }: AddNoteButtonProps) {
value={summary} value={summary}
onChange={(e) => setSummary(e.target.value)} onChange={(e) => setSummary(e.target.value)}
placeholder="Short label (optional)" placeholder="Short label (optional)"
className="mt-1.5 w-full rounded-md border border-default bg-input px-2.5 py-1 text-[0.75rem] text-heading placeholder:text-muted-foreground focus:outline-none focus:border-accent focus:ring-1 focus:ring-accent/30" className="mt-1.5 w-full rounded-md border border-default bg-input px-2.5 py-1 text-xs text-heading placeholder:text-muted-foreground focus:outline-none focus:border-accent focus:ring-1 focus:ring-accent/30"
/> />
<div className="mt-1.5 flex items-center gap-2"> <div className="mt-1.5 flex items-center gap-2">
<button <button
onClick={handleSubmit} onClick={handleSubmit}
disabled={busy || !text.trim()} disabled={busy || !text.trim()}
className="flex items-center gap-1 rounded-md bg-accent px-2.5 py-1 text-[0.75rem] font-medium text-white disabled:opacity-40 hover:bg-accent-hover transition-colors" className="flex items-center gap-1 rounded-md bg-accent px-2.5 py-1 text-xs font-medium text-white disabled:opacity-40 hover:bg-accent-hover transition-colors"
> >
<Check size={11} /> Add <Check size={11} /> Add
</button> </button>
<button <button
onClick={reset} onClick={reset}
disabled={busy} disabled={busy}
className="text-[0.75rem] text-muted-foreground hover:text-heading" className="text-xs text-muted-foreground hover:text-heading"
> >
Cancel Cancel
</button> </button>

View File

@@ -11,8 +11,8 @@
* and renders the section. Loading/refresh logic lives in the parent * and renders the section. Loading/refresh logic lives in the parent
* (AssistantChatPage) so it can coordinate with the chat send cycle. * (AssistantChatPage) so it can coordinate with the chat send cycle.
*/ */
import { Loader2 } from 'lucide-react' import { useState } from 'react'
import { cn } from '@/lib/utils' import { ChevronDown, ChevronRight, Loader2 } from 'lucide-react'
import type { SessionFact } from '@/api/sessionFacts' import type { SessionFact } from '@/api/sessionFacts'
import { WhatWeKnowItem } from './WhatWeKnowItem' import { WhatWeKnowItem } from './WhatWeKnowItem'
import { AddNoteButton } from './AddNoteButton' import { AddNoteButton } from './AddNoteButton'
@@ -23,32 +23,65 @@ interface WhatWeKnowProps {
onUpdateFact: (factId: string, text: string, summary: string | null) => Promise<void> | void onUpdateFact: (factId: string, text: string, summary: string | null) => Promise<void> | void
onDeleteFact: (factId: string) => Promise<void> | void onDeleteFact: (factId: string) => Promise<void> | void
loading?: boolean loading?: boolean
/** Used as the sessionStorage key for the engineer's collapse preference.
* When the parent re-keys this component on session change, the lazy
* initializer reads fresh state for the new session. */
sessionId?: string | null
} }
const COLLAPSE_STORAGE_KEY = 'rf-whatweknow-collapsed'
// First-render auto-collapse threshold. Past this, the section is hidden by
// default so Questions / Diagnostic Checks stay above the fold. The engineer's
// explicit toggle (stored per-session) always wins over this heuristic.
const AUTO_COLLAPSE_THRESHOLD = 5
export function WhatWeKnow({ export function WhatWeKnow({
facts, facts,
onAddNote, onAddNote,
onUpdateFact, onUpdateFact,
onDeleteFact, onDeleteFact,
loading, loading,
sessionId,
}: WhatWeKnowProps) { }: WhatWeKnowProps) {
const count = facts.length const count = facts.length
const [collapsed, setCollapsed] = useState<boolean>(() => {
if (sessionId) {
try {
const stored = sessionStorage.getItem(`${COLLAPSE_STORAGE_KEY}:${sessionId}`)
if (stored !== null) return stored === '1'
} catch { /* ignore */ }
}
return count >= AUTO_COLLAPSE_THRESHOLD
})
const toggle = () => {
setCollapsed(prev => {
const next = !prev
if (sessionId) {
try { sessionStorage.setItem(`${COLLAPSE_STORAGE_KEY}:${sessionId}`, next ? '1' : '0') } catch { /* ignore */ }
}
return next
})
}
return ( return (
<section <section className="rounded-lg p-3 -mx-1 mb-1">
className={cn( <div className={collapsed ? '' : 'pb-2'}>
'rounded-lg p-3 -mx-1 mb-1', <div className="flex items-center gap-2 pl-0.5">
// Subtle green-to-transparent gradient distinguishes this section <button
// from the rest of the lane (mockup 01-session-primary.png). type="button"
'bg-gradient-to-b from-success/[0.05] to-transparent', onClick={toggle}
)} aria-expanded={!collapsed}
> aria-label={collapsed ? 'Expand What we know' : 'Collapse What we know'}
<div className="pb-2"> className="flex items-center gap-2 text-[0.625rem] font-semibold uppercase tracking-[1.2px] text-muted-foreground hover:text-heading transition-colors"
<div className="flex items-center gap-2 text-[10px] font-semibold uppercase tracking-[1.2px] text-muted-foreground pl-0.5"> >
<span className="w-1.5 h-1.5 rounded-full bg-success" /> {collapsed ? <ChevronRight size={10} /> : <ChevronDown size={10} />}
What we know <span className="w-1.5 h-1.5 rounded-full bg-success" />
<span className="text-muted-foreground">·</span> What we know
<span className="tabular-nums">{count}</span> <span className="text-muted-foreground">·</span>
<span className="tabular-nums">{count}</span>
</button>
{loading && ( {loading && (
<span <span
className="ml-auto flex items-center gap-1 text-[0.625rem] font-medium normal-case tracking-normal text-muted-foreground" className="ml-auto flex items-center gap-1 text-[0.625rem] font-medium normal-case tracking-normal text-muted-foreground"
@@ -61,29 +94,33 @@ export function WhatWeKnow({
</div> </div>
</div> </div>
{count === 0 && loading && ( {!collapsed && (
<div className="space-y-2 px-1 py-2"> <>
<div className="h-3 w-3/4 rounded bg-elevated/60 animate-pulse" /> {count === 0 && loading && (
<div className="h-3 w-1/2 rounded bg-elevated/60 animate-pulse" /> <div className="space-y-2 px-1 py-2">
</div> <div className="h-3 w-3/4 rounded bg-elevated/60 animate-pulse" />
<div className="h-3 w-1/2 rounded bg-elevated/60 animate-pulse" />
</div>
)}
{count === 0 && !loading && (
<div className="text-xs text-muted-foreground italic px-1 py-2">
Nothing confirmed yet facts appear here as the engineer answers questions and runs checks.
</div>
)}
{facts.map((fact) => (
<WhatWeKnowItem
key={fact.id}
fact={fact}
onSave={(text, summary) => onUpdateFact(fact.id, text, summary)}
onDelete={() => onDeleteFact(fact.id)}
/>
))}
<AddNoteButton onAdd={onAddNote} />
</>
)} )}
{count === 0 && !loading && (
<div className="text-[0.75rem] text-muted-foreground italic px-1 py-2">
Nothing confirmed yet facts appear here as the engineer answers questions and runs checks.
</div>
)}
{facts.map((fact) => (
<WhatWeKnowItem
key={fact.id}
fact={fact}
onSave={(text, summary) => onUpdateFact(fact.id, text, summary)}
onDelete={() => onDeleteFact(fact.id)}
/>
))}
<AddNoteButton onAdd={onAddNote} />
</section> </section>
) )
} }

View File

@@ -79,20 +79,20 @@ export function WhatWeKnowItem({ fact, onSave, onDelete }: WhatWeKnowItemProps)
value={draftSummary} value={draftSummary}
onChange={(e) => setDraftSummary(e.target.value)} onChange={(e) => setDraftSummary(e.target.value)}
placeholder="Short label (e.g. 'rules out tenant/license')" placeholder="Short label (e.g. 'rules out tenant/license')"
className="mt-1.5 w-full rounded-md border border-default bg-input px-2.5 py-1 text-[0.75rem] text-heading placeholder:text-muted-foreground focus:outline-none focus:border-accent focus:ring-1 focus:ring-accent/30" className="mt-1.5 w-full rounded-md border border-default bg-input px-2.5 py-1 text-xs text-heading placeholder:text-muted-foreground focus:outline-none focus:border-accent focus:ring-1 focus:ring-accent/30"
/> />
<div className="mt-1.5 flex items-center gap-2"> <div className="mt-1.5 flex items-center gap-2">
<button <button
onClick={handleSave} onClick={handleSave}
disabled={busy || !draftText.trim()} disabled={busy || !draftText.trim()}
className="flex items-center gap-1 rounded-md bg-accent px-2.5 py-1 text-[0.75rem] font-medium text-white disabled:opacity-40 hover:bg-accent-hover transition-colors" className="flex items-center gap-1 rounded-md bg-accent px-2.5 py-1 text-xs font-medium text-white disabled:opacity-40 hover:bg-accent-hover transition-colors"
> >
<Check size={11} /> Save <Check size={11} /> Save
</button> </button>
<button <button
onClick={handleCancel} onClick={handleCancel}
disabled={busy} disabled={busy}
className="text-[0.75rem] text-muted-foreground hover:text-heading" className="text-xs text-muted-foreground hover:text-heading"
> >
Cancel Cancel
</button> </button>
@@ -104,7 +104,7 @@ export function WhatWeKnowItem({ fact, onSave, onDelete }: WhatWeKnowItemProps)
return ( return (
<div <div
className={cn( className={cn(
'group rounded-lg border-l-[3px] border-l-success border border-success/25 bg-success-dim/20 p-3 mb-2', 'group rounded-lg border border-default/40 p-3 mb-2',
busy && 'opacity-60', busy && 'opacity-60',
)} )}
> >

View File

@@ -1,4 +1,4 @@
import { useState, useEffect, useRef } from 'react' import { useCallback, useState, useEffect, useRef } from 'react'
import { Palette, Upload, Trash2, Loader2 } from 'lucide-react' import { Palette, Upload, Trash2, Loader2 } from 'lucide-react'
import { getBranding, updateBranding, deleteLogo } from '@/api/branding' import { getBranding, updateBranding, deleteLogo } from '@/api/branding'
import type { BrandingInfo } from '@/api/branding' import type { BrandingInfo } from '@/api/branding'
@@ -23,11 +23,7 @@ export function BrandingSettings({ teamId }: BrandingSettingsProps) {
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
const fileInputRef = useRef<HTMLInputElement>(null) const fileInputRef = useRef<HTMLInputElement>(null)
useEffect(() => { const loadBranding = useCallback(async () => {
loadBranding()
}, [teamId])
const loadBranding = async () => {
setIsLoading(true) setIsLoading(true)
try { try {
const data = await getBranding(teamId) const data = await getBranding(teamId)
@@ -44,7 +40,11 @@ export function BrandingSettings({ teamId }: BrandingSettingsProps) {
} finally { } finally {
setIsLoading(false) setIsLoading(false)
} }
} }, [teamId])
useEffect(() => {
loadBranding()
}, [loadBranding])
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => { const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0] const file = e.target.files?.[0]

View File

@@ -47,9 +47,9 @@ export function NodeEditorPanel({ nodeId, onClose, onSelectType }: NodeEditorPan
if (node) { if (node) {
// eslint-disable-next-line react-hooks/set-state-in-effect // eslint-disable-next-line react-hooks/set-state-in-effect
setDraft(cloneWithoutChildren(node)) setDraft(cloneWithoutChildren(node))
// eslint-disable-next-line react-hooks/set-state-in-effect
setIsDirty(false) setIsDirty(false)
// eslint-disable-next-line react-hooks/set-state-in-effect
setShowDeleteConfirm(false) setShowDeleteConfirm(false)
} }
}, [nodeId, node?.type]) // eslint-disable-line react-hooks/exhaustive-deps }, [nodeId, node?.type]) // eslint-disable-line react-hooks/exhaustive-deps

View File

@@ -261,7 +261,7 @@ export function TreeCanvas() {
}) })
setExpandedNodeId(null) setExpandedNodeId(null)
}, },
[pendingLinks, treeStructure, updateNode] [addNode, pendingLinks, treeStructure, updateNode]
) )
// ── Cancel new node ── // ── Cancel new node ──

View File

@@ -18,7 +18,7 @@ export function useCachedQuota() {
if (cachedResult && Date.now() - cachedResult.timestamp < CACHE_TTL_MS) { if (cachedResult && Date.now() - cachedResult.timestamp < CACHE_TTL_MS) {
// eslint-disable-next-line react-hooks/set-state-in-effect // eslint-disable-next-line react-hooks/set-state-in-effect
setAiEnabled(cachedResult.aiEnabled) setAiEnabled(cachedResult.aiEnabled)
// eslint-disable-next-line react-hooks/set-state-in-effect
setIsLoading(false) setIsLoading(false)
return return
} }

View File

@@ -153,7 +153,7 @@ export function AccountSettingsPage() {
useEffect(() => { useEffect(() => {
loadData() loadData()
}, []) }, []) // eslint-disable-line react-hooks/exhaustive-deps -- initial account load; mutations call loadData explicitly
const loadData = async () => { const loadData = async () => {
setIsLoading(true) setIsLoading(true)

View File

@@ -5,7 +5,7 @@ import { handoffsApi } from '@/api/handoffs'
import { timeAgo } from '@/lib/timeAgo' import { timeAgo } from '@/lib/timeAgo'
import type { HandoffResponse } from '@/types/branching' import type { HandoffResponse } from '@/types/branching'
import { HandoffContextScreen } from '@/components/flowpilot/HandoffContextScreen' import { HandoffContextScreen } from '@/components/flowpilot/HandoffContextScreen'
import { Sparkles, Send, Loader2, MessageSquare, Paperclip, Terminal, X, RotateCcw, ImagePlus, ListChecks, FileText, CheckCircle2, ArrowUpRight, ArrowRight, MoreHorizontal, Pause, Plus, Copy, Check } from 'lucide-react' import { Sparkles, Send, Loader2, MessageSquare, Paperclip, Terminal, X, RotateCcw, ImagePlus, ListChecks, FileText, CheckCircle2, ArrowUpRight, MoreHorizontal, Pause, Plus } from 'lucide-react'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { uploadsApi } from '@/api/uploads' import { uploadsApi } from '@/api/uploads'
import type { PendingUpload } from '@/types/upload' import type { PendingUpload } from '@/types/upload'
@@ -88,9 +88,6 @@ export default function AssistantChatPage() {
// composer. Click prefills the input; first send hides the strip; explicit // composer. Click prefills the input; first send hides the strip; explicit
// X also hides. Per-session lifetime — a refresh wipes the state, which is // X also hides. Per-session lifetime — a refresh wipes the state, which is
// fine because the senior can re-open the Context overlay. // fine because the senior can re-open the Context overlay.
const [chipsHidden, setChipsHidden] = useState(false)
const [selectedChipCardIdx, setSelectedChipCardIdx] = useState<number | null>(null)
const [copiedChipCmd, setCopiedChipCmd] = useState(false)
const [chats, setChats] = useState<ChatListItem[]>([]) const [chats, setChats] = useState<ChatListItem[]>([])
const [activeChatId, setActiveChatId] = useState<string | null>(() => { const [activeChatId, setActiveChatId] = useState<string | null>(() => {
if (urlSessionId) return urlSessionId if (urlSessionId) return urlSessionId
@@ -267,6 +264,15 @@ export default function AssistantChatPage() {
// path: post-claim the chat surface had no messages and the senior // path: post-claim the chat surface had no messages and the senior
// landed on a blank pane). // landed on a blank pane).
const loadedChatIdsRef = useRef<Set<string>>(new Set()) const loadedChatIdsRef = useRef<Set<string>>(new Set())
const guardCurrentChat = useCallback((expectedChatId: string, source: string) => {
if (currentChatRef.current === expectedChatId) return true
console.warn('[AssistantChat] Discarded stale async result', {
source,
expectedChatId,
currentChatId: currentChatRef.current,
})
return false
}, [])
// Persist active chat ID to sessionStorage // Persist active chat ID to sessionStorage
useEffect(() => { useEffect(() => {
@@ -612,7 +618,7 @@ export default function AssistantChatPage() {
} }
window.addEventListener(PILOT_INLINE_SCRIPT_EVENT, handler as EventListener) window.addEventListener(PILOT_INLINE_SCRIPT_EVENT, handler as EventListener)
return () => window.removeEventListener(PILOT_INLINE_SCRIPT_EVENT, handler as EventListener) return () => window.removeEventListener(PILOT_INLINE_SCRIPT_EVENT, handler as EventListener)
}, [activeFix]) }, [activeFix, activeChatId])
const loadChats = async () => { const loadChats = async () => {
try { try {
@@ -684,7 +690,7 @@ export default function AssistantChatPage() {
try { try {
const list = await sessionFactsApi.list(chatId) const list = await sessionFactsApi.list(chatId)
// Guard: discard stale fetch if the user switched chats mid-flight. // Guard: discard stale fetch if the user switched chats mid-flight.
if (currentChatRef.current !== chatId) return if (!guardCurrentChat(chatId, 'refreshFacts')) return
setFacts(list) setFacts(list)
// Auto-open the task lane when the session has facts so the engineer // Auto-open the task lane when the session has facts so the engineer
// can see them — without this, a session with only facts (no open // can see them — without this, a session with only facts (no open
@@ -699,7 +705,7 @@ export default function AssistantChatPage() {
// Best-effort — facts are accessory state. Surfacing a toast on every // Best-effort — facts are accessory state. Surfacing a toast on every
// refetch failure would be noisy; the empty state explains the absence. // refetch failure would be noisy; the empty state explains the absence.
} }
}, []) }, [guardCurrentChat])
// Phase 3 — active suggested fix + resolution-note preview. // Phase 3 — active suggested fix + resolution-note preview.
// Declared BEFORE refreshSessionDerived / handleAddNote so the useCallback // Declared BEFORE refreshSessionDerived / handleAddNote so the useCallback
@@ -707,7 +713,7 @@ export default function AssistantChatPage() {
const refreshActiveFix = useCallback(async (chatId: string) => { const refreshActiveFix = useCallback(async (chatId: string) => {
try { try {
const fix = await sessionSuggestedFixesApi.getActive(chatId) const fix = await sessionSuggestedFixesApi.getActive(chatId)
if (currentChatRef.current !== chatId) return if (!guardCurrentChat(chatId, 'refreshActiveFix')) return
setActiveFix((prev) => { setActiveFix((prev) => {
// If the active fix changed (AI emitted a new SUGGEST_FIX that // If the active fix changed (AI emitted a new SUGGEST_FIX that
// superseded the prior), close the script panel so the engineer // superseded the prior), close the script panel so the engineer
@@ -719,7 +725,7 @@ export default function AssistantChatPage() {
// No-fix-yet (404) is normalized to null inside the client. Genuine // No-fix-yet (404) is normalized to null inside the client. Genuine
// failures stay silent — accessory state, not load-bearing. // failures stay silent — accessory state, not load-bearing.
} }
}, []) }, [guardCurrentChat])
// Kind-aware preview fetch: Resolve hits /resolution-note/preview, // Kind-aware preview fetch: Resolve hits /resolution-note/preview,
// Escalate hits /escalation-package/preview. They're cached separately // Escalate hits /escalation-package/preview. They're cached separately
@@ -733,7 +739,7 @@ export default function AssistantChatPage() {
const p = effectiveKind === 'resolve' const p = effectiveKind === 'resolve'
? await sessionSuggestedFixesApi.getResolutionNotePreview(chatId) ? await sessionSuggestedFixesApi.getResolutionNotePreview(chatId)
: await sessionSuggestedFixesApi.getEscalationPackagePreview(chatId) : await sessionSuggestedFixesApi.getEscalationPackagePreview(chatId)
if (currentChatRef.current !== chatId) return if (!guardCurrentChat(chatId, 'refreshPreview')) return
setPreviewData(p) setPreviewData(p)
} catch (err: unknown) { } catch (err: unknown) {
const status = (err as { response?: { status?: number } })?.response?.status const status = (err as { response?: { status?: number } })?.response?.status
@@ -745,7 +751,7 @@ export default function AssistantChatPage() {
} finally { } finally {
setPreviewLoading(false) setPreviewLoading(false)
} }
}, [previewKind]) }, [guardCurrentChat, previewKind])
// Trigger preview refresh with a 500ms debounce. The backend cache short- // Trigger preview refresh with a 500ms debounce. The backend cache short-
// circuits same-state calls, but the network round-trip is still avoidable // circuits same-state calls, but the network round-trip is still avoidable
@@ -880,7 +886,7 @@ export default function AssistantChatPage() {
} }
// No draft, no template — route to the Script Builder tab. // No draft, no template — route to the Script Builder tab.
setChatTab('script_builder') setChatTab('script_builder')
}, [activeFix]) }, [activeFix, activeChatId])
// Phase 9 Task 13: TemplateMatchPanel "I ran this" — stamps applied_at so the // Phase 9 Task 13: TemplateMatchPanel "I ran this" — stamps applied_at so the
// ProposalBanner transitions from Proposed to Verifying. Shared useCallback so // ProposalBanner transitions from Proposed to Verifying. Shared useCallback so
@@ -903,6 +909,10 @@ export default function AssistantChatPage() {
try { try {
const updated = await sessionSuggestedFixesApi.patchOutcome(activeChatId, activeFix.id, outcome, notes) const updated = await sessionSuggestedFixesApi.patchOutcome(activeChatId, activeFix.id, outcome, notes)
setActiveFix(updated) setActiveFix(updated)
// Banner and script panel are linked surfaces: once an outcome is
// recorded, the script-execution affordance has done its job, so close
// it alongside the banner state transition.
setScriptPanelOpen(false)
// Reset apply tracking state since we now have a terminal outcome. // Reset apply tracking state since we now have a terminal outcome.
setPostApplyMsgCount(0) setPostApplyMsgCount(0)
setNudgeSilenced(false) setNudgeSilenced(false)
@@ -1108,13 +1118,13 @@ export default function AssistantChatPage() {
// Guard: if the user switched to a different chat while this API call was // Guard: if the user switched to a different chat while this API call was
// in flight (e.g. clicked "New Chat"), discard stale results so we don't // in flight (e.g. clicked "New Chat"), discard stale results so we don't
// clobber the new session's task lane state. // clobber the new session's task lane state.
if (currentChatRef.current !== chatId) return if (!guardCurrentChat(chatId, 'selectChat')) return
setActiveSessionStatus(detail.status) setActiveSessionStatus(detail.status)
setActivePsaTicketId(detail.psa_ticket_id) setActivePsaTicketId(detail.psa_ticket_id)
if (detail.psa_ticket_id) { if (detail.psa_ticket_id) {
integrationsApi.getTicket(detail.psa_ticket_id) integrationsApi.getTicket(detail.psa_ticket_id)
.then(ticket => { .then(ticket => {
if (currentChatRef.current !== chatId) return if (!guardCurrentChat(chatId, 'selectChat.ticket')) return
setLinkedTicket(ticket) setLinkedTicket(ticket)
}) })
.catch(() => {}) .catch(() => {})
@@ -1149,7 +1159,7 @@ export default function AssistantChatPage() {
} catch { } catch {
setMessages([]) setMessages([])
} }
}, [refreshSessionDerived]) }, [guardCurrentChat, refreshSessionDerived, resetSessionDerivedState])
const handleAIAnalysis = useCallback(async () => { const handleAIAnalysis = useCallback(async () => {
if (!urlSessionId || !magicHandoff) return if (!urlSessionId || !magicHandoff) return
@@ -1162,7 +1172,7 @@ export default function AssistantChatPage() {
setMagicState('dismissed') setMagicState('dismissed')
void loadChats() void loadChats()
await selectChat(urlSessionId) await selectChat(urlSessionId)
if (currentChatRef.current !== sentForChatId) return if (!guardCurrentChat(sentForChatId, 'handleAIAnalysis.afterSelect')) return
const assessment = magicHandoff.ai_assessment_data const assessment = magicHandoff.ai_assessment_data
const snapshot = magicHandoff.snapshot as Record<string, unknown> const snapshot = magicHandoff.snapshot as Record<string, unknown>
@@ -1192,7 +1202,7 @@ export default function AssistantChatPage() {
setMessages(prev => [...prev, { role: 'user', content: briefing }]) setMessages(prev => [...prev, { role: 'user', content: briefing }])
setLoading(true) setLoading(true)
const response = await aiSessionsApi.sendChatMessage(urlSessionId, { message: briefing }) const response = await aiSessionsApi.sendChatMessage(urlSessionId, { message: briefing })
if (currentChatRef.current !== sentForChatId) return if (!guardCurrentChat(sentForChatId, 'handleAIAnalysis.chatResponse')) return
setMessages(prev => [ setMessages(prev => [
...prev, ...prev,
{ {
@@ -1233,7 +1243,7 @@ export default function AssistantChatPage() {
setActiveOptionKey(null) setActiveOptionKey(null)
setLoading(false) setLoading(false)
} }
}, [urlSessionId, magicHandoff, setSearchParams, selectChat]) }, [guardCurrentChat, urlSessionId, magicHandoff, setSearchParams, selectChat])
const handleNewChat = async () => { const handleNewChat = async () => {
// Invalidate currentChatRef BEFORE the await so any in-flight handleSend/handleTaskSubmit // Invalidate currentChatRef BEFORE the await so any in-flight handleSend/handleTaskSubmit
@@ -1295,7 +1305,6 @@ export default function AssistantChatPage() {
.map((u) => u.preview) .map((u) => u.preview)
setInput('') setInput('')
setPendingUploads([]) setPendingUploads([])
setChipsHidden(true)
setMessages(prev => [...prev, { role: 'user', content: userMessage, imageUrls: imageUrls.length > 0 ? imageUrls : undefined }]) setMessages(prev => [...prev, { role: 'user', content: userMessage, imageUrls: imageUrls.length > 0 ? imageUrls : undefined }])
setLoading(true) setLoading(true)
@@ -1306,7 +1315,7 @@ export default function AssistantChatPage() {
upload_ids: completedUploadIds.length > 0 ? completedUploadIds : undefined, upload_ids: completedUploadIds.length > 0 ? completedUploadIds : undefined,
}) })
// Guard: discard if user switched to a different chat while this was in flight // Guard: discard if user switched to a different chat while this was in flight
if (currentChatRef.current !== sentForChatId) return if (!guardCurrentChat(sentForChatId, 'handleSend')) return
analytics.aiFeatureUsed({ feature: 'assistant_chat' }) analytics.aiFeatureUsed({ feature: 'assistant_chat' })
setMessages(prev => [ setMessages(prev => [
...prev, ...prev,
@@ -1396,7 +1405,7 @@ export default function AssistantChatPage() {
try { try {
const response = await aiSessionsApi.sendChatMessage(activeChatId, { message: userMessage }) const response = await aiSessionsApi.sendChatMessage(activeChatId, { message: userMessage })
// Guard: discard if user switched to a different chat while this was in flight // Guard: discard if user switched to a different chat while this was in flight
if (currentChatRef.current !== sentForChatId) return if (!guardCurrentChat(sentForChatId, 'handleTaskSubmit')) return
setMessages(prev => [ setMessages(prev => [
...prev, ...prev,
{ role: 'assistant', content: response.content, suggestedFlows: response.suggested_flows, fork: response.fork, actions: response.actions, questions: response.questions }, { role: 'assistant', content: response.content, suggestedFlows: response.suggested_flows, fork: response.fork, actions: response.actions, questions: response.questions },
@@ -1491,7 +1500,7 @@ export default function AssistantChatPage() {
const response = await aiSessionsApi.sendChatMessage(session.session_id, { message: resumePrompt }) const response = await aiSessionsApi.sendChatMessage(session.session_id, { message: resumePrompt })
// Guard: discard if user switched to a different chat while this was in flight // Guard: discard if user switched to a different chat while this was in flight
if (currentChatRef.current !== session.session_id) return if (!guardCurrentChat(session.session_id, 'handleResumeNew')) return
setMessages(prev => [ setMessages(prev => [
...prev, ...prev,
{ role: 'assistant', content: response.content, suggestedFlows: response.suggested_flows, fork: response.fork, actions: response.actions, questions: response.questions }, { role: 'assistant', content: response.content, suggestedFlows: response.suggested_flows, fork: response.fork, actions: response.actions, questions: response.questions },
@@ -1760,27 +1769,10 @@ export default function AssistantChatPage() {
)} )}
</div> </div>
{/* Desktop actions — shown when session is active and has messages */} {/* Desktop actions — Resolve + Escalate stay first-class; everything
else (Context / New Ticket / Update Ticket / Pause) folds behind
a single kebab to keep the header to two visible primary actions. */}
<div className="hidden sm:flex items-center gap-1.5"> <div className="hidden sm:flex items-center gap-1.5">
{magicHandoff && (
<button
onClick={openHandoffContextOverlay}
disabled={overlayLoading}
title="Show the handoff context the original engineer sent"
className="flex items-center gap-1.5 rounded-lg border border-default px-3 py-1.5 text-xs font-medium text-muted-foreground hover:text-foreground hover:border-hover disabled:opacity-40 transition-colors"
>
<Sparkles size={13} />
Context
</button>
)}
{activePsaTicketId && (
<button
onClick={() => { setSpinOffHint(undefined); setShowNewTicket(true) }}
className="flex items-center gap-1 px-2 py-1 text-xs text-muted-foreground border border-default rounded-[5px] hover:border-hover hover:text-primary transition-colors"
>
<Plus className="w-3 h-3" /> New Ticket
</button>
)}
{isActive && ( {isActive && (
<> <>
<button <button
@@ -1793,55 +1785,76 @@ export default function AssistantChatPage() {
Resolve Resolve
</button> </button>
<div className="relative"> <div className="relative">
<button <button
onClick={handleEscalateClick} onClick={handleEscalateClick}
disabled={!canAct} disabled={!canAct}
data-conclude-outcome="escalated" data-conclude-outcome="escalated"
className="flex items-center gap-1.5 rounded-lg bg-warning-dim border border-warning/20 px-3 py-1.5 text-xs font-medium text-warning hover:bg-warning/20 disabled:opacity-40 disabled:pointer-events-none transition-colors" className="flex items-center gap-1.5 rounded-lg bg-warning-dim border border-warning/20 px-3 py-1.5 text-xs font-medium text-warning hover:bg-warning/20 disabled:opacity-40 disabled:pointer-events-none transition-colors"
> >
<ArrowUpRight size={13} /> <ArrowUpRight size={13} />
Escalate Escalate
</button> </button>
{escalateIntercept && ( {escalateIntercept && (
<EscalateInterceptDialog <EscalateInterceptDialog
fixTitle={escalateIntercept.fixTitle} fixTitle={escalateIntercept.fixTitle}
onChoose={handleInterceptChoice} onChoose={handleInterceptChoice}
onClose={() => setEscalateIntercept(null)} onClose={() => setEscalateIntercept(null)}
/> />
)} )}
</div> </div>
</> </>
)} )}
{messages.length >= 2 && ( {(magicHandoff || activePsaTicketId || messages.length >= 2) && (
<button
onClick={() => setShowStatusUpdate(true)}
disabled={loading}
className="flex items-center gap-1.5 rounded-lg bg-accent-dim border border-accent/20 px-3 py-1.5 text-xs font-medium text-accent hover:bg-accent/20 disabled:opacity-40 disabled:pointer-events-none transition-colors"
>
<FileText size={13} />
{updateLabel}
</button>
)}
{/* Overflow: Pause / — */}
{isActive && messages.length >= 2 && (
<div className="relative"> <div className="relative">
<button <button
onClick={() => setShowOverflow(!showOverflow)} onClick={() => setShowOverflow(!showOverflow)}
className="flex items-center justify-center rounded-lg px-2 py-1.5 text-muted-foreground hover:text-foreground hover:bg-[rgba(255,255,255,0.06)] transition-colors" className="flex items-center justify-center rounded-lg px-2 py-1.5 text-muted-foreground hover:text-foreground hover:bg-[rgba(255,255,255,0.06)] transition-colors"
aria-label="More session actions"
> >
<MoreHorizontal size={16} /> <MoreHorizontal size={16} />
</button> </button>
{showOverflow && ( {showOverflow && (
<> <>
<div className="fixed inset-0 z-40" onClick={() => setShowOverflow(false)} /> <div className="fixed inset-0 z-40" onClick={() => setShowOverflow(false)} />
<div className="absolute right-0 top-full mt-1 z-50 w-36 rounded-lg border border-border bg-card py-1 shadow-lg"> <div className="absolute right-0 top-full mt-1 z-50 w-44 rounded-lg border border-border bg-card py-1 shadow-lg">
<button {magicHandoff && (
onClick={() => { setShowOverflow(false); aiSessionsApi.pauseSession(activeChatId).then(() => setActiveSessionStatus('paused')).catch(() => toast.error('Failed to pause')) }} <button
className="flex w-full items-center gap-2 px-3 py-2 text-xs text-muted-foreground hover:text-foreground hover:bg-[rgba(255,255,255,0.06)] transition-colors" onClick={() => { setShowOverflow(false); openHandoffContextOverlay() }}
> disabled={overlayLoading}
<Pause size={13} /> className="flex w-full items-center gap-2 px-3 py-2 text-xs text-muted-foreground hover:text-foreground hover:bg-[rgba(255,255,255,0.06)] transition-colors disabled:opacity-40"
Pause >
</button> <Sparkles size={13} />
Context
</button>
)}
{activePsaTicketId && (
<button
onClick={() => { setShowOverflow(false); setSpinOffHint(undefined); setShowNewTicket(true) }}
className="flex w-full items-center gap-2 px-3 py-2 text-xs text-muted-foreground hover:text-foreground hover:bg-[rgba(255,255,255,0.06)] transition-colors"
>
<Plus size={13} />
New Ticket
</button>
)}
{messages.length >= 2 && (
<button
onClick={() => { setShowOverflow(false); setShowStatusUpdate(true) }}
disabled={loading}
className="flex w-full items-center gap-2 px-3 py-2 text-xs text-muted-foreground hover:text-foreground hover:bg-[rgba(255,255,255,0.06)] transition-colors disabled:opacity-40"
>
<FileText size={13} />
{updateLabel}
</button>
)}
{isActive && messages.length >= 2 && (
<button
onClick={() => { setShowOverflow(false); aiSessionsApi.pauseSession(activeChatId).then(() => setActiveSessionStatus('paused')).catch(() => toast.error('Failed to pause')) }}
className="flex w-full items-center gap-2 px-3 py-2 text-xs text-muted-foreground hover:text-foreground hover:bg-[rgba(255,255,255,0.06)] transition-colors"
>
<Pause size={13} />
Pause
</button>
)}
</div> </div>
</> </>
)} )}
@@ -1849,12 +1862,14 @@ export default function AssistantChatPage() {
)} )}
</div> </div>
{/* Mobile: single overflow menu */} {/* Mobile: single overflow menu — same items as desktop kebab plus
{messages.length >= 2 && ( Resolve/Escalate (which live in the visible row on desktop). */}
{(magicHandoff || activePsaTicketId || messages.length >= 2) && (
<div className="sm:hidden relative"> <div className="sm:hidden relative">
<button <button
onClick={() => setShowOverflow(!showOverflow)} onClick={() => setShowOverflow(!showOverflow)}
className="flex items-center justify-center rounded-lg px-2 py-1.5 text-muted-foreground hover:text-foreground hover:bg-[rgba(255,255,255,0.06)] transition-colors" className="flex items-center justify-center rounded-lg px-2 py-1.5 text-muted-foreground hover:text-foreground hover:bg-[rgba(255,255,255,0.06)] transition-colors"
aria-label="Session actions"
> >
<MoreHorizontal size={18} /> <MoreHorizontal size={18} />
</button> </button>
@@ -1862,7 +1877,7 @@ export default function AssistantChatPage() {
<> <>
<div className="fixed inset-0 z-40" onClick={() => setShowOverflow(false)} /> <div className="fixed inset-0 z-40" onClick={() => setShowOverflow(false)} />
<div className="absolute right-0 top-full mt-1 z-50 w-44 rounded-lg border border-border bg-card py-1 shadow-lg"> <div className="absolute right-0 top-full mt-1 z-50 w-44 rounded-lg border border-border bg-card py-1 shadow-lg">
{isActive && ( {isActive && messages.length >= 2 && (
<> <>
<button <button
onClick={() => { setShowOverflow(false); handleResolveClick() }} onClick={() => { setShowOverflow(false); handleResolveClick() }}
@@ -1893,15 +1908,36 @@ export default function AssistantChatPage() {
</div> </div>
</> </>
)} )}
<button {magicHandoff && (
onClick={() => { setShowOverflow(false); setShowStatusUpdate(true) }} <button
disabled={loading} onClick={() => { setShowOverflow(false); openHandoffContextOverlay() }}
className="flex w-full items-center gap-2 px-3 py-2.5 text-xs text-accent hover:bg-accent-dim transition-colors disabled:opacity-40" disabled={overlayLoading}
> className="flex w-full items-center gap-2 px-3 py-2.5 text-xs text-muted-foreground hover:text-foreground hover:bg-[rgba(255,255,255,0.06)] transition-colors disabled:opacity-40"
<FileText size={14} /> >
{updateLabel} <Sparkles size={14} />
</button> Context
{isActive && ( </button>
)}
{activePsaTicketId && (
<button
onClick={() => { setShowOverflow(false); setSpinOffHint(undefined); setShowNewTicket(true) }}
className="flex w-full items-center gap-2 px-3 py-2.5 text-xs text-muted-foreground hover:text-foreground hover:bg-[rgba(255,255,255,0.06)] transition-colors"
>
<Plus size={14} />
New Ticket
</button>
)}
{messages.length >= 2 && (
<button
onClick={() => { setShowOverflow(false); setShowStatusUpdate(true) }}
disabled={loading}
className="flex w-full items-center gap-2 px-3 py-2.5 text-xs text-accent hover:bg-accent-dim transition-colors disabled:opacity-40"
>
<FileText size={14} />
{updateLabel}
</button>
)}
{isActive && messages.length >= 2 && (
<button <button
onClick={() => { setShowOverflow(false); aiSessionsApi.pauseSession(activeChatId).then(() => setActiveSessionStatus('paused')).catch(() => toast.error('Failed to pause')) }} onClick={() => { setShowOverflow(false); aiSessionsApi.pauseSession(activeChatId).then(() => setActiveSessionStatus('paused')).catch(() => toast.error('Failed to pause')) }}
className="flex w-full items-center gap-2 px-3 py-2.5 text-xs text-muted-foreground hover:text-foreground hover:bg-[rgba(255,255,255,0.06)] transition-colors" className="flex w-full items-center gap-2 px-3 py-2.5 text-xs text-muted-foreground hover:text-foreground hover:bg-[rgba(255,255,255,0.06)] transition-colors"
@@ -1932,8 +1968,11 @@ export default function AssistantChatPage() {
Hidden (not unmounted) when Script Builder tab is active so Hidden (not unmounted) when Script Builder tab is active so
scroll position and input state are preserved. */} scroll position and input state are preserved. */}
<div className={cn('flex-1 min-h-0 flex flex-col', chatTab !== 'chat' && 'hidden')}> <div className={cn('flex-1 min-h-0 flex flex-col', chatTab !== 'chat' && 'hidden')}>
{/* Messages */} {/* Messages — scroll container is full width (so the scrollbar lives at
<div className="flex-1 overflow-y-auto px-4 sm:px-6 py-4 space-y-4"> the chat-column edge) but content is centered to max-w-3xl to match
the composer below, giving the column a single anchor. */}
<div className="flex-1 overflow-y-auto px-4 sm:px-6 py-4">
<div className="max-w-3xl mx-auto space-y-4">
{messages.length === 0 && !loading && ( {messages.length === 0 && !loading && (
<div className="flex flex-col items-center justify-center h-full text-center"> <div className="flex flex-col items-center justify-center h-full text-center">
<div className="w-16 h-16 rounded-full bg-accent-dim flex items-center justify-center mb-4"> <div className="w-16 h-16 rounded-full bg-accent-dim flex items-center justify-center mb-4">
@@ -1948,26 +1987,41 @@ export default function AssistantChatPage() {
</p> </p>
</div> </div>
)} )}
{messages.map((msg, i) => ( {(() => {
<ChatMessage // Action emphasis is shown on the *current* turn only — i.e. the
key={i} // latest assistant message when active items are pending and the
role={msg.role} // magic-moment hero has dismissed. The TaskLane remains the
content={msg.content} // canonical list; this is just an inline cue.
suggestedFlows={msg.suggestedFlows} let lastAssistantIdx = -1
imageUrls={msg.imageUrls} for (let i = messages.length - 1; i >= 0; i--) {
/> if (messages[i].role === 'assistant') { lastAssistantIdx = i; break }
))} }
const showActionEmphasis = magicState === 'dismissed'
&& (activeQuestions.length + activeActions.length) > 0
const turnActionCount = activeQuestions.length + activeActions.length
return messages.map((msg, i) => (
<ChatMessage
key={i}
role={msg.role}
content={msg.content}
suggestedFlows={msg.suggestedFlows}
imageUrls={msg.imageUrls}
actionCount={i === lastAssistantIdx && showActionEmphasis ? turnActionCount : undefined}
/>
))
})()}
{loading && ( {loading && (
<div className="flex gap-3"> <div className="flex gap-3">
<div className="w-8 h-8 rounded-full bg-primary/15 flex items-center justify-center"> <div className="w-8 h-8 rounded-full bg-primary/15 flex items-center justify-center">
<Sparkles size={14} className="text-primary" /> <Sparkles size={14} className="text-primary" />
</div> </div>
<div className="bg-input border border-border rounded-2xl px-4 py-3"> <div className="bg-input border border-border rounded-xl px-4 py-3">
<Loader2 size={16} className="animate-spin text-primary" /> <Loader2 size={16} className="animate-spin text-primary" />
</div> </div>
</div> </div>
)} )}
<div ref={messagesEndRef} /> <div ref={messagesEndRef} />
</div>
</div> </div>
{/* Phase 8: ProposalBanner — mounted above the composer */} {/* Phase 8: ProposalBanner — mounted above the composer */}
@@ -1988,8 +2042,9 @@ export default function AssistantChatPage() {
{/* Phase 9: InlineNoTemplateDialog — drafted-script evaluation case, {/* Phase 9: InlineNoTemplateDialog — drafted-script evaluation case,
rendered in the chat region above the composer so all three rendered in the chat region above the composer so all three
option cards fit side-by-side without the TaskLane's narrow width. */} option cards fit side-by-side without the TaskLane's narrow width.
{scriptPanelOpen && activeFix && activeChatId && !activeFix.script_template_id && activeFix.ai_drafted_script && ( Hidden when the banner is collapsed: the two surfaces are linked. */}
{scriptPanelOpen && !bannerCollapsed && activeFix && activeChatId && !activeFix.script_template_id && activeFix.ai_drafted_script && (
<InlineNoTemplateDialog <InlineNoTemplateDialog
fix={activeFix} fix={activeFix}
onClose={() => setScriptPanelOpen(false)} onClose={() => setScriptPanelOpen(false)}
@@ -1998,143 +2053,6 @@ export default function AssistantChatPage() {
/> />
)} )}
{/* Task-lane shortcut chips: visible after the magic-moment
dissolves when the task lane has loaded items. Each card
links directly to the corresponding diagnostic card in the
task lane — clicking opens the lane (if closed) and scrolls
to that card. Sourced from actual task lane items, not the
AI's free-text suggested_steps, so the card the user lands
on has full detail (description, command, etc.). */}
{!chipsHidden &&
(activeActions.length > 0 || activeQuestions.length > 0) &&
magicState === 'dismissed' && (() => {
const chipItems = [
...activeActions.slice(0, 4).map((a, ai) => ({
label: a.label,
cardIdx: activeQuestions.length + ai,
description: a.description,
command: a.command ?? null,
type: 'action' as const,
})),
...activeQuestions.slice(0, Math.max(0, 4 - Math.min(activeActions.length, 4))).map((q, qi) => ({
label: q.text,
cardIdx: qi,
description: q.context ?? null,
command: null,
type: 'question' as const,
})),
]
const selectedChip = chipItems.find(c => c.cardIdx === selectedChipCardIdx) ?? null
return (
<div className="px-3 sm:px-6 pt-2 pb-0.5 shrink-0">
<div className="max-w-3xl mx-auto">
<div className="flex items-center gap-2 mb-1.5">
<p className="font-sans text-[0.625rem] uppercase tracking-wider text-muted-foreground">
Suggested checks
</p>
<button
type="button"
onClick={() => { setChipsHidden(true); setSelectedChipCardIdx(null) }}
aria-label="Hide suggestions"
className="p-0.5 rounded text-muted-foreground hover:text-foreground hover:bg-elevated transition-colors"
>
<X size={11} />
</button>
</div>
{/* Inline detail card — shown when a chip is selected */}
{selectedChip && (
<div className="mb-2 rounded-lg border border-default bg-card p-3 animate-fade-in">
<div className="flex items-start justify-between gap-2 mb-1.5">
<span className="text-[0.8125rem] font-medium text-heading leading-snug">{selectedChip.label}</span>
<button
onClick={() => setSelectedChipCardIdx(null)}
className="shrink-0 p-0.5 rounded text-muted-foreground hover:text-foreground transition-colors"
aria-label="Close detail"
>
<X size={12} />
</button>
</div>
{selectedChip.description && (
<p className="text-[0.6875rem] text-muted-foreground mb-2 leading-relaxed">{selectedChip.description}</p>
)}
{selectedChip.command && (
<div className="rounded-md bg-code border border-default/50 px-3 py-2 flex items-start gap-2 mb-2.5">
<code className="flex-1 text-[0.75rem] font-mono text-heading whitespace-pre-wrap break-all leading-relaxed">{selectedChip.command}</code>
<button
onClick={async () => {
try {
await navigator.clipboard.writeText(selectedChip.command!)
} catch {
try {
const el = document.createElement('textarea')
el.value = selectedChip.command!
el.style.cssText = 'position:fixed;opacity:0;pointer-events:none'
document.body.appendChild(el)
el.select()
document.execCommand('copy')
document.body.removeChild(el)
} catch { return }
}
setCopiedChipCmd(true)
setTimeout(() => setCopiedChipCmd(false), 1500)
}}
className="shrink-0 p-1 rounded text-muted-foreground hover:text-heading hover:bg-elevated transition-colors mt-0.5"
title={copiedChipCmd ? 'Copied!' : 'Copy command'}
>
{copiedChipCmd
? <Check size={13} className="text-success" />
: <Copy size={13} />
}
</button>
</div>
)}
<button
onClick={() => {
setSelectedChipCardIdx(null)
if (!showTaskLane) setShowTaskLane(true)
const el = document.getElementById(`task-lane-card-${selectedChip.cardIdx}`)
if (el) {
setTimeout(() => el.scrollIntoView({ behavior: 'smooth', block: 'nearest' }), showTaskLane ? 0 : 200)
}
}}
className="flex items-center gap-1 text-[0.75rem] font-medium text-accent-text hover:text-accent transition-colors"
>
<ArrowRight size={11} />
Open in Tasks panel
</button>
</div>
)}
<div className="flex gap-2 overflow-x-auto pb-1" style={{ scrollbarWidth: 'none' }}>
{chipItems.map((item) => {
const isSelected = item.cardIdx === selectedChipCardIdx
return (
<button
key={item.cardIdx}
type="button"
onClick={() => {
setCopiedChipCmd(false)
setSelectedChipCardIdx(isSelected ? null : item.cardIdx)
}}
className={cn(
'flex items-start gap-2 rounded-lg border px-3 py-2.5 text-left transition-colors shrink-0 w-[172px]',
isSelected
? 'border-accent/50 bg-accent-dim'
: 'border-default bg-card hover:bg-accent-dim hover:border-accent/30',
)}
>
<ArrowRight size={12} className="text-accent-text shrink-0 mt-0.5" />
<span className="text-xs text-foreground line-clamp-2 leading-snug">{item.label}</span>
</button>
)
})}
</div>
</div>
</div>
)
})()}
{/* Rich Input */} {/* Rich Input */}
<div className="px-3 sm:px-6 py-3 shrink-0"> <div className="px-3 sm:px-6 py-3 shrink-0">
<div <div
@@ -2182,7 +2100,7 @@ export default function AssistantChatPage() {
{upload.preview ? ( {upload.preview ? (
<img src={upload.preview} alt="" className="w-full h-full object-cover" /> <img src={upload.preview} alt="" className="w-full h-full object-cover" />
) : ( ) : (
<div className="w-full h-full flex items-center justify-center text-[0.5rem] text-muted-foreground px-1 text-center"> <div className="w-full h-full flex items-center justify-center text-[0.625rem] text-muted-foreground px-1 text-center">
{upload.file.name.split('.').pop()?.toUpperCase()} {upload.file.name.split('.').pop()?.toUpperCase()}
</div> </div>
)} )}
@@ -2210,7 +2128,7 @@ export default function AssistantChatPage() {
{showLogs && ( {showLogs && (
<div className="px-4 pb-1"> <div className="px-4 pb-1">
<div className="flex items-center justify-between mb-1"> <div className="flex items-center justify-between mb-1">
<span className="text-[0.625rem] uppercase tracking-wide text-muted-foreground font-sans">Paste logs or error output</span> <span className="text-[0.625rem] uppercase tracking-wide text-muted-foreground">Paste logs or error output</span>
<button type="button" onClick={() => { setShowLogs(false); setLogContent('') }} className="text-muted-foreground hover:text-foreground"><X size={14} /></button> <button type="button" onClick={() => { setShowLogs(false); setLogContent('') }} className="text-muted-foreground hover:text-foreground"><X size={14} /></button>
</div> </div>
<textarea <textarea
@@ -2350,6 +2268,8 @@ export default function AssistantChatPage() {
loading={loading} loading={loading}
whatWeKnowSlot={ whatWeKnowSlot={
<WhatWeKnow <WhatWeKnow
key={activeChatId ?? 'no-session'}
sessionId={activeChatId}
facts={facts} facts={facts}
onAddNote={handleAddNote} onAddNote={handleAddNote}
onUpdateFact={handleUpdateFact} onUpdateFact={handleUpdateFact}
@@ -2359,7 +2279,7 @@ export default function AssistantChatPage() {
} }
bottomSlot={ bottomSlot={
<> <>
{scriptPanelOpen && activeFix && activeChatId && activeFix.script_template_id && ( {scriptPanelOpen && !bannerCollapsed && activeFix && activeChatId && activeFix.script_template_id && (
<TemplateMatchPanel <TemplateMatchPanel
fix={activeFix} fix={activeFix}
sessionId={activeChatId} sessionId={activeChatId}
@@ -2371,7 +2291,7 @@ export default function AssistantChatPage() {
<button <button
onClick={() => handleOpenPreview('resolve')} onClick={() => handleOpenPreview('resolve')}
className={cn( className={cn(
'flex items-center gap-1.5 text-[0.75rem] font-medium transition-colors', 'flex items-center gap-1.5 text-xs font-medium transition-colors',
previewKind === 'resolve' previewKind === 'resolve'
? 'text-success' ? 'text-success'
: 'text-accent-text hover:text-heading', : 'text-accent-text hover:text-heading',
@@ -2383,7 +2303,7 @@ export default function AssistantChatPage() {
<button <button
onClick={() => handleOpenPreview('escalate')} onClick={() => handleOpenPreview('escalate')}
className={cn( className={cn(
'flex items-center gap-1.5 text-[0.75rem] font-medium transition-colors', 'flex items-center gap-1.5 text-xs font-medium transition-colors',
previewKind === 'escalate' previewKind === 'escalate'
? 'text-warning' ? 'text-warning'
: 'text-muted-foreground hover:text-heading', : 'text-muted-foreground hover:text-heading',
@@ -2421,6 +2341,8 @@ export default function AssistantChatPage() {
loading={loading} loading={loading}
whatWeKnowSlot={ whatWeKnowSlot={
<WhatWeKnow <WhatWeKnow
key={activeChatId ?? 'no-session'}
sessionId={activeChatId}
facts={facts} facts={facts}
onAddNote={handleAddNote} onAddNote={handleAddNote}
onUpdateFact={handleUpdateFact} onUpdateFact={handleUpdateFact}
@@ -2430,7 +2352,7 @@ export default function AssistantChatPage() {
} }
bottomSlot={ bottomSlot={
<> <>
{scriptPanelOpen && activeFix && activeChatId && activeFix.script_template_id && ( {scriptPanelOpen && !bannerCollapsed && activeFix && activeChatId && activeFix.script_template_id && (
<TemplateMatchPanel <TemplateMatchPanel
fix={activeFix} fix={activeFix}
sessionId={activeChatId} sessionId={activeChatId}
@@ -2442,7 +2364,7 @@ export default function AssistantChatPage() {
<button <button
onClick={() => handleOpenPreview('resolve')} onClick={() => handleOpenPreview('resolve')}
className={cn( className={cn(
'flex items-center gap-1.5 text-[0.75rem] font-medium transition-colors', 'flex items-center gap-1.5 text-xs font-medium transition-colors',
previewKind === 'resolve' previewKind === 'resolve'
? 'text-success' ? 'text-success'
: 'text-accent-text hover:text-heading', : 'text-accent-text hover:text-heading',
@@ -2454,7 +2376,7 @@ export default function AssistantChatPage() {
<button <button
onClick={() => handleOpenPreview('escalate')} onClick={() => handleOpenPreview('escalate')}
className={cn( className={cn(
'flex items-center gap-1.5 text-[0.75rem] font-medium transition-colors', 'flex items-center gap-1.5 text-xs font-medium transition-colors',
previewKind === 'escalate' previewKind === 'escalate'
? 'text-warning' ? 'text-warning'
: 'text-muted-foreground hover:text-heading', : 'text-muted-foreground hover:text-heading',
@@ -2552,7 +2474,7 @@ export default function AssistantChatPage() {
{/* Handoff context overlay — re-opened from the toolbar */} {/* Handoff context overlay — re-opened from the toolbar */}
{overlayHandoff && ( {overlayHandoff && (
<div <div
className="fixed inset-0 z-50 flex items-start justify-center overflow-y-auto bg-black/60 backdrop-blur-sm p-4 sm:p-8 animate-fade-in" className="fixed inset-0 z-50 flex items-start justify-center overflow-y-auto bg-black/70 p-4 sm:p-8 animate-fade-in"
onClick={(e) => { onClick={(e) => {
if (e.target === e.currentTarget) setOverlayHandoff(null) if (e.target === e.currentTarget) setOverlayHandoff(null)
}} }}

View File

@@ -40,7 +40,7 @@ export function MyTreesPage() {
useEffect(() => { useEffect(() => {
loadMyTrees() loadMyTrees()
}, [user?.id]) }, [user?.id]) // eslint-disable-line react-hooks/exhaustive-deps -- reload only when the owner identity changes
const loadMyTrees = async () => { const loadMyTrees = async () => {
if (!user?.id) return if (!user?.id) return

View File

@@ -118,7 +118,7 @@ export function ProceduralEditorPage() {
} }
return () => { reset() } return () => { reset() }
}, [id]) }, [id]) // eslint-disable-line react-hooks/exhaustive-deps -- editor init is keyed to route id; store actions are stable
useEffect(() => { useEffect(() => {
useProceduralEditorStore.getState().validate() useProceduralEditorStore.getState().validate()

View File

@@ -155,7 +155,7 @@ export function ProceduralNavigationPage() {
return () => { return () => {
if (timerRef.current) clearInterval(timerRef.current) if (timerRef.current) clearInterval(timerRef.current)
} }
}, [treeId]) }, [treeId]) // eslint-disable-line react-hooks/exhaustive-deps -- session load is keyed to route tree id
// Check for PSA connection on mount // Check for PSA connection on mount
useEffect(() => { useEffect(() => {

View File

@@ -57,7 +57,7 @@ export function SessionDetailPage() {
if (id) { if (id) {
loadSession() loadSession()
} }
}, [id]) }, [id]) // eslint-disable-line react-hooks/exhaustive-deps -- detail reload is keyed to route session id
// Auto-show rating modal for completed sessions with library steps // Auto-show rating modal for completed sessions with library steps
useEffect(() => { useEffect(() => {

View File

@@ -269,7 +269,7 @@ export default function SessionHistoryPage() {
<PageMeta title="Sessions" /> <PageMeta title="Sessions" />
<div className="container mx-auto px-4 py-6 sm:px-6 sm:py-8"> <div className="container mx-auto px-4 py-6 sm:px-6 sm:py-8">
{/* Page heading */} {/* Page heading */}
<div className="mb-6"> <div className="mb-6" data-testid="session-history-heading">
<h1 className="text-2xl font-heading font-bold text-foreground sm:text-3xl">Session History</h1> <h1 className="text-2xl font-heading font-bold text-foreground sm:text-3xl">Session History</h1>
<p className="mt-1 text-sm text-muted-foreground">View and manage your sessions</p> <p className="mt-1 text-sm text-muted-foreground">View and manage your sessions</p>
</div> </div>
@@ -279,6 +279,7 @@ export default function SessionHistoryPage() {
{TABS.map((tab) => ( {TABS.map((tab) => (
<button <button
key={tab.id} key={tab.id}
data-testid={`session-history-tab-${tab.id}`}
onClick={() => setActiveTab(tab.id)} onClick={() => setActiveTab(tab.id)}
className={cn( className={cn(
'px-4 py-2 text-sm transition-colors whitespace-nowrap', 'px-4 py-2 text-sm transition-colors whitespace-nowrap',
@@ -614,6 +615,7 @@ export default function SessionHistoryPage() {
Close Close
</button> </button>
<button <button
data-testid="flow-session-resume"
onClick={() => navigate(getSessionResumePath(session.tree_id, session.tree_snapshot?.tree_type), { state: { sessionId: session.id } })} onClick={() => navigate(getSessionResumePath(session.tree_id, session.tree_snapshot?.tree_type), { state: { sessionId: session.id } })}
className="rounded-md bg-primary px-3 py-2 text-sm font-medium text-white hover:brightness-110 transition-all" className="rounded-md bg-primary px-3 py-2 text-sm font-medium text-white hover:brightness-110 transition-all"
> >

View File

@@ -234,7 +234,7 @@ export function TreeEditorPage() {
return () => { return () => {
reset() reset()
} }
}, [id, isEditMode, canCreateTrees]) }, [id, isEditMode, canCreateTrees]) // eslint-disable-line react-hooks/exhaustive-deps -- initialization is keyed to route/editability state
// Handle unsaved changes warning // Handle unsaved changes warning
useEffect(() => { useEffect(() => {
@@ -391,7 +391,7 @@ export function TreeEditorPage() {
} finally { } finally {
setSaving(false) setSaving(false)
} }
}, [isSaving, isEditMode, id, editorMode, getTreeForSave, markSaved, navigate]) }, [isSaving, isEditMode, id, editorMode, getTreeForSave, markSaved, navigate, setSaving])
const handlePublish = useCallback(async () => { const handlePublish = useCallback(async () => {
if (isSaving) return if (isSaving) return
@@ -472,7 +472,7 @@ export function TreeEditorPage() {
} finally { } finally {
setSaving(false) setSaving(false)
} }
}, [isSaving, isEditMode, id, editorMode, validate, getTreeForSave, markSaved, navigate]) }, [isSaving, isEditMode, id, editorMode, validate, getTreeForSave, markSaved, navigate, setSaving])
// Keep handleSave for backward compatibility (Ctrl+S shortcut) // Keep handleSave for backward compatibility (Ctrl+S shortcut)
const handleSave = useCallback(async () => { const handleSave = useCallback(async () => {

View File

@@ -292,7 +292,7 @@ export function TreeNavigationPage() {
if (treeId) { if (treeId) {
loadTreeAndSession() loadTreeAndSession()
} }
}, [treeId]) }, [treeId]) // eslint-disable-line react-hooks/exhaustive-deps -- route tree id is the load boundary
// Check for PSA connection on mount // Check for PSA connection on mount
useEffect(() => { useEffect(() => {