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
**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.
**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
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.
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.
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`).
4. Local `main` fast-forwarded to remote; `feat/fix-pending-verification` deleted locally and on the remote.
**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`.
- 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.
- 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."
- Updated `docs/plans/2026-05-01-issue-cleanup-plan.md` with section 1/2 status and validation.
- 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).
## 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:
- 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.
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.
## 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
- 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
- [ ] **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
- [ ] **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.
- [ ] **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.
@@ -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.
- [ ] **(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 {
setLoading(false)
}
}, [])
}, [fetchUser, isAuthenticated, setLoading])
return <RouterProvider router={router} />
}

View File

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

View File

@@ -74,7 +74,7 @@ export function ActionCardGroup({ actions, onSubmit, disabled, stale }: ActionCa
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"
>
<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} />
<span>{pendingCount} diagnostic check{pendingCount !== 1 ? 's' : ''} not completed</span>
</div>
@@ -95,7 +95,7 @@ export function ActionCardGroup({ actions, onSubmit, disabled, stale }: ActionCa
</div>
<div className="space-y-0.5">
{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' ? (
<Check size={10} className="text-success shrink-0" />
) : (
@@ -118,7 +118,7 @@ export function ActionCardGroup({ actions, onSubmit, disabled, stale }: ActionCa
<div>
<button
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} />
<span>Run All ({commandActions.length} commands)</span>
@@ -128,12 +128,12 @@ export function ActionCardGroup({ actions, onSubmit, disabled, stale }: ActionCa
{showRunAll && (
<div className="mt-2 rounded-lg border border-default bg-code p-3">
<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
</span>
<button
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} />
<span>Copy</span>
@@ -167,23 +167,23 @@ export function ActionCardGroup({ actions, onSubmit, disabled, stale }: ActionCa
<div className="flex-1 min-w-0">
<div className="text-[0.8125rem] font-medium text-heading">{action.label}</div>
{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>
{/* Status badge for handled cards */}
{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' && (
<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>
{/* Command with copy button */}
{action.command && response.state !== 'skipped' && (
<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}
</code>
<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">
<button
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} />
Paste Result
</button>
<button
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
</button>
<button
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} />
Skip
@@ -237,14 +237,14 @@ export function ActionCardGroup({ actions, onSubmit, disabled, stale }: ActionCa
<button
onClick={() => updateCard(idx, { state: 'done' })}
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} />
Done
</button>
<button
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
</button>
@@ -282,7 +282,7 @@ export function ActionCardGroup({ actions, onSubmit, disabled, stale }: ActionCa
</button>
{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} />
<span>Failed to send</span>
<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 { SuggestedFlowCard } from './SuggestedFlowCard'
import type { SuggestedFlow } from '@/types/copilot'
@@ -8,9 +8,14 @@ interface ChatMessageProps {
content: string
suggestedFlows?: SuggestedFlow[]
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 (
<div className={`flex gap-3 ${role === 'user' ? 'flex-row-reverse' : ''}`}>
{/* Avatar */}
@@ -41,20 +46,32 @@ export function ChatMessage({ role, content, suggestedFlows, imageUrls }: ChatMe
</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
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'
? '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>
{/* Suggested flows (assistant only) */}
{role === 'assistant' && suggestedFlows && suggestedFlows.length > 0 && (
<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
</span>
{suggestedFlows.map(flow => (

View File

@@ -85,7 +85,7 @@ export function ChatSidebar({
<div className="flex-1 overflow-y-auto py-2">
{pinnedChats.length > 0 && (
<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
</span>
</div>
@@ -159,7 +159,7 @@ export function ChatSidebarCollapsedBar({
<History size={14} />
<span>History</span>
{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>
<div className="flex-1" />
@@ -203,7 +203,7 @@ function ChatItem({
<div className="flex-1 min-w-0">
{confirming ? (
<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
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"
@@ -222,12 +222,12 @@ function ChatItem({
<div className="flex items-center gap-1.5 min-w-0">
<div className="text-[0.8125rem] font-medium truncate">{chat.title}</div>
{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}
</span>
)}
{(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
</span>
)}

View File

@@ -268,7 +268,7 @@ export function ConcludeSessionModal({
)}
<div
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
? 'bg-primary text-white'
: (i < ['select-outcome', 'add-notes', 'summary'].indexOf(step))
@@ -280,7 +280,7 @@ export function ConcludeSessionModal({
</div>
<span
className={cn(
'text-xs font-sans text-xs',
'text-xs',
step === s ? 'text-foreground' : 'text-muted-foreground'
)}
>
@@ -329,7 +329,7 @@ export function ConcludeSessionModal({
<div className="space-y-4">
{/* Selected outcome badge */}
<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} />
<span className={selectedOutcome.color}>{selectedOutcome.label}</span>
</div>
@@ -342,7 +342,7 @@ export function ConcludeSessionModal({
</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)
</label>
<textarea
@@ -383,7 +383,7 @@ export function ConcludeSessionModal({
<div className="space-y-4">
{/* Outcome badge */}
{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} />
<span className={selectedOutcome.color}>{selectedOutcome.label}</span>
</div>
@@ -396,7 +396,7 @@ export function ConcludeSessionModal({
style={{ borderColor: 'var(--color-border-default)' }}
>
<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" />
Ticket Notes
</span>
@@ -488,7 +488,7 @@ export function ConcludeSessionModal({
style={{ borderColor: 'var(--color-border-default)' }}
>
<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" />
Status Update
</span>

View File

@@ -27,11 +27,11 @@ export function SuggestedFlowCard({ flow }: SuggestedFlowCardProps) {
<span className="text-[0.8125rem] font-medium text-foreground truncate">
{flow.tree_name}
</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}
</span>
</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}
</p>
</div>

View File

@@ -1,7 +1,7 @@
import { useState, useEffect, useRef, useCallback } from 'react'
import {
Copy, Check, SkipForward, Terminal, ChevronDown, ChevronUp,
Send, Clipboard, Loader2, PanelRightClose, MessageCircleQuestion, Eye,
Send, Clipboard, Loader2, PanelRightClose, Pencil, HelpCircle, Eye,
} from 'lucide-react'
import { cn } from '@/lib/utils'
import { toast } from '@/lib/toast'
@@ -31,6 +31,62 @@ interface 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 {
questions: QuestionItem[]
actions: ActionItem[]
@@ -98,6 +154,7 @@ export function TaskLane({ questions, actions, sessionId, onSubmit, onClose, loa
const [showRunAll, setShowRunAll] = useState(false)
const [showPreview, setShowPreview] = useState(false)
const [copiedKey, setCopiedKey] = useState<string | null>(null)
const [expandedHelpKey, setExpandedHelpKey] = useState<string | null>(null)
// ── Resize state ──
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 })),
actions: actionsRef.current.map(a => ({ label: a.label, command: a.command, description: a.description })),
responses: tasksRef.current as unknown as Array<Record<string, unknown>>,
}).catch(() => { /* silent best-effort save */ })
}).catch(() => { /* silent - best-effort save */ })
}, 2000)
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
useEffect(() => {
if (sessionId) {
const saved = loadTaskState(sessionId)
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)
return
}
}
// eslint-disable-next-line react-hooks/set-state-in-effect -- intentional: syncs derived state from prop changes
setTasks([
...questions.map((q): QuestionResponse => ({
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: '',
})),
])
}, [questions, actions]) // eslint-disable-line react-hooks/exhaustive-deps
}, [questions, actions, sessionId])
const updateTask = (idx: number, updates: Partial<TaskResponse>) => {
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 actionTasks = tasks.filter(t => t.type === 'action') as ActionResponse[]
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>
)}
{/* 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">
Tasks
<span className={cn(
'text-[10px] font-semibold px-2 py-0.5 rounded-full',
allHandled
? 'bg-success-dim text-success'
: 'bg-accent-dim text-accent-text'
)}>
{allHandled ? '✓ Ready' : `${doneCount}/${totalCount}`}
</span>
{allHandled ? (
<span className="flex items-center gap-1 text-[0.625rem] font-semibold uppercase tracking-wider text-success">
<Check size={10} /> Ready
</span>
) : (
<span className="text-[0.625rem] font-medium tabular-nums text-muted-foreground">
{doneCount}/{totalCount}
</span>
)}
{loading && (
<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"
>
<Loader2 size={10} className="animate-spin" />
@@ -329,7 +405,7 @@ export function TaskLane({ questions, actions, sessionId, onSubmit, onClose, loa
{questionTasks.length > 0 && (
<section>
<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" />
Questions
{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') {
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">
<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 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>
)
}
@@ -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 className="flex justify-between">
<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>
)
@@ -377,33 +453,47 @@ export function TaskLane({ questions, actions, sessionId, onSubmit, onClose, loa
autoFocus
value={q.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..."
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}
/>
<div className="mt-1.5 flex items-center gap-2">
<button
onClick={() => updateTask(idx, { state: 'done' })}
disabled={!q.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"
>
<Check size={11} /> Answer
</button>
<button
onClick={() => updateTask(idx, { state: 'pending', value: '' })}
className="text-[0.75rem] text-muted-foreground hover:text-heading"
>
Cancel
</button>
<div className="mt-1.5 flex items-center justify-between gap-2">
<div className="flex items-center gap-2">
<button
onClick={() => submitAndAdvance(idx, q.value)}
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>
<button
onClick={() => updateTask(idx, { state: 'pending', value: '' })}
className="text-xs text-muted-foreground hover:text-heading"
>
Cancel
</button>
</div>
<span className="text-[0.625rem] text-muted-foreground tabular-nums">
submit · newline
</span>
</div>
</div>
) : (
<div className="mt-2 flex items-center gap-2">
<button
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
onClick={() => updateTask(idx, { state: 'skipped' })}
@@ -424,7 +514,7 @@ export function TaskLane({ questions, actions, sessionId, onSubmit, onClose, loa
{actionTasks.length > 0 && (
<section>
<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" />
Diagnostic Checks
{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">
<button
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} />
Run All ({commandActions.length} commands)
@@ -447,16 +537,16 @@ export function TaskLane({ questions, actions, sessionId, onSubmit, onClose, loa
{showRunAll && (
<div className="mt-2 rounded-lg border border-default bg-code p-3">
<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
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 ? 'Copied' : 'Copy'}
</button>
</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>
@@ -468,10 +558,10 @@ export function TaskLane({ questions, actions, sessionId, onSubmit, onClose, loa
if (a.state === 'done') {
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">
<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>
)
@@ -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 className="flex justify-between">
<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>
)
@@ -490,10 +580,49 @@ export function TaskLane({ questions, actions, sessionId, onSubmit, onClose, loa
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 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 className="flex items-start justify-between gap-2">
<div className="min-w-0 flex-1">
<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 && (
<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
value={a.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..."
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}
/>
<div className="mt-1.5 flex items-center gap-2">
<button
onClick={() => updateTask(idx, { state: 'done' })}
disabled={!a.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"
>
<Check size={11} /> Done
</button>
<button
onClick={() => updateTask(idx, { state: 'pending', value: '' })}
className="text-[0.75rem] text-muted-foreground hover:text-heading"
>
Cancel
</button>
<div className="mt-1.5 flex items-center justify-between gap-2">
<div className="flex items-center gap-2">
<button
onClick={() => submitAndAdvance(idx, a.value)}
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>
<button
onClick={() => updateTask(idx, { state: 'pending', value: '' })}
className="text-xs text-muted-foreground hover:text-heading"
>
Cancel
</button>
</div>
<span className="text-[0.625rem] text-muted-foreground tabular-nums">
submit · newline
</span>
</div>
</div>
) : (
<div className="mt-2 flex items-center gap-2">
<button
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'}
</button>
@@ -602,7 +745,7 @@ export function TaskLane({ questions, actions, sessionId, onSubmit, onClose, loa
<div className="mb-2">
<button
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} />
Preview ({handledCount}/{totalCount} done)
@@ -616,6 +759,7 @@ export function TaskLane({ questions, actions, sessionId, onSubmit, onClose, loa
</div>
)}
<button
ref={sendButtonRef}
onClick={handleSubmit}
disabled={!anyHandled || loading || submitting}
className={cn(

View File

@@ -296,7 +296,7 @@ export function CommandPalette({ open, onClose }: CommandPaletteProps) {
}
return result
}, [query, searchFlows, searchSessions, searchAISessions, user])
}, [query, searchFlows, searchSessions, searchAISessions, user, onPilotSession])
// Flatten all items for keyboard navigation
const flatItems: PaletteItem[] = builtGroups.flatMap(g => g.items)
@@ -401,6 +401,7 @@ export function CommandPalette({ open, onClose }: CommandPaletteProps) {
return (
<button
key={item.id}
data-testid={item.id === 'flowpilot' ? 'command-palette-flowpilot' : undefined}
onClick={() => handleSelect(item)}
onMouseEnter={() => setSelectedIndex(itemGlobalIdx)}
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 { foldersApi } from '@/api/folders'
import type { FolderListItem } from '@/types'
@@ -16,26 +16,7 @@ export function AddToFolderMenu({ treeId, onFolderCreated }: AddToFolderMenuProp
const [isLoading, setIsLoading] = useState(false)
const menuRef = useRef<HTMLDivElement>(null)
useEffect(() => {
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 () => {
const loadFoldersAndAssignments = useCallback(async () => {
setIsLoading(true)
try {
const foldersData = await foldersApi.list()
@@ -59,7 +40,26 @@ export function AddToFolderMenu({ treeId, onFolderCreated }: AddToFolderMenuProp
} finally {
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) => {
try {

View File

@@ -56,6 +56,14 @@ function getIndentedName(folders: FolderListItem[], folderId: string): string {
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({
folder,
parentId: initialParentId,
@@ -110,14 +118,6 @@ export function FolderEditModal({
})
}, [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(() => {
if (folder) {
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 type { TreeListItem, TreeShare, TreeVisibility } from '@/types'
import { treesApi } from '@/api/trees'
@@ -20,16 +20,7 @@ export function ShareTreeModal({ tree, isOpen, onClose }: ShareTreeModalProps) {
const [allowForking, setAllowForking] = useState(true)
const [visibility, setVisibility] = useState<TreeVisibility>('private')
useEffect(() => {
if (isOpen) {
loadShares()
// Reset state
setCopied(false)
setAllowForking(true)
}
}, [isOpen, tree.id])
const loadShares = async () => {
const loadShares = useCallback(async () => {
try {
const sharesData = await treesApi.listShares(tree.id)
setShares(sharesData)
@@ -40,7 +31,16 @@ export function ShareTreeModal({ tree, isOpen, onClose }: ShareTreeModalProps) {
} catch (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 () => {
setIsGenerating(true)

View File

@@ -57,7 +57,7 @@ function TabButton({
aria-selected={active}
onClick={onClick}
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',
active
? '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) {
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="absolute left-0 top-0 bottom-0 w-[3px] bg-warning" />
<div className="border-t border-warning/30 bg-card px-5 py-3 animate-slide-up">
<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={15} />
</div>
<Sparkles size={16} className="text-warning shrink-0 mt-1" />
<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 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
</span>
</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}
</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}
</div>
{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} />
Matches an existing Script Library template one-click apply
</div>
@@ -92,13 +89,13 @@ function ProposedBanner({ fix, onApply, onDismiss, onToggleCollapsed }: Proposal
)}
<button
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
</button>
<button
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
<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'
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="absolute left-0 top-0 bottom-0 w-[3px] bg-warning" />
<div className="border-t border-warning/30 bg-card px-5 py-3 animate-slide-up">
<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="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" />
<polyline points="12 6 12 12 16 14" />
</svg>
<span className="absolute inset-[-3px] rounded-lg animate-pulse-amber pointer-events-none" />
</div>
<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">
<circle cx="12" cy="12" r="10" />
<polyline points="12 6 12 12 16 14" />
</svg>
<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 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}
</span>
</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?
</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.
</div>
</div>
@@ -159,7 +152,7 @@ function VerifyingBanner({ fix, onOutcome }: ProposalBannerProps) {
const notes = window.prompt('What did you run / skip?')
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
</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")')
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" />
Waiting to verify
@@ -181,14 +174,14 @@ function VerifyingBanner({ fix, onOutcome }: ProposalBannerProps) {
const reason = window.prompt("Why didn't it work? (optional)")
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} />
Didn't work
</button>
<button
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} />
It worked
@@ -209,25 +202,22 @@ function formatRelativeMinutes(iso: string): string {
function PartialBanner({ fix, onOutcome, onApply }: ProposalBannerProps) {
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="absolute left-0 top-0 bottom-0 w-[3px] bg-info" />
<div className="border-t border-info/30 bg-card px-5 py-3 animate-slide-up">
<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={15} />
</div>
<Info size={16} className="text-info shrink-0 mt-1" />
<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 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
</span>
</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}
</div>
{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">
<span className="not-italic font-bold text-info text-[10.5px] uppercase tracking-[0.6px]">Note</span>
<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-[0.625rem] uppercase tracking-[0.6px]">Note</span>
<span>{fix.partial_notes}</span>
</div>
)}
@@ -238,19 +228,19 @@ function PartialBanner({ fix, onOutcome, onApply }: ProposalBannerProps) {
const reason = window.prompt("Why didn't it work? (optional)")
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
</button>
<button
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
</button>
<button
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
</button>
@@ -262,25 +252,22 @@ function PartialBanner({ fix, onOutcome, onApply }: ProposalBannerProps) {
function PendingBanner({ fix, onOutcome, onDismiss }: ProposalBannerProps) {
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="absolute left-0 top-0 bottom-0 w-[3px] bg-info" />
<div className="border-t border-info/30 bg-card px-5 py-3 animate-slide-up">
<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={15} />
</div>
<Clock3 size={16} className="text-info shrink-0 mt-1" />
<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 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
</span>
</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}
</div>
{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">
<span className="not-italic font-bold text-info text-[10.5px] uppercase tracking-[0.6px]">Waiting on</span>
<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-[0.625rem] uppercase tracking-[0.6px]">Waiting on</span>
<span>{fix.pending_reason}</span>
</div>
)}
@@ -288,7 +275,7 @@ function PendingBanner({ fix, onOutcome, onDismiss }: ProposalBannerProps) {
<div className="flex items-center gap-2 shrink-0 pt-0.5">
<button
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
</button>
@@ -300,7 +287,7 @@ function PendingBanner({ fix, onOutcome, onDismiss }: ProposalBannerProps) {
)
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
</button>
@@ -309,13 +296,13 @@ function PendingBanner({ fix, onOutcome, onDismiss }: ProposalBannerProps) {
const reason = window.prompt("Why didn't it work? (optional)")
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
</button>
<button
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} />
It worked
@@ -339,37 +326,34 @@ function AIConfirmingBanner({ fix, onAcceptAIProposal, onRejectAIProposal }: Pro
: 'was partially applied'
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="absolute left-0 top-0 bottom-0 w-[3px] bg-accent" />
<div className="border-t border-accent/30 bg-card px-5 py-3 animate-slide-up">
<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={15} />
</div>
<Sparkles size={16} className="text-accent shrink-0 mt-1" />
<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 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'}
</span>
</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?
</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.'}
</div>
</div>
<div className="flex items-center gap-2 shrink-0 pt-0.5">
<button
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
</button>
<button
onClick={onAcceptAIProposal}
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
? 'bg-success text-[#0a1a12]'
: 'bg-danger text-[#180808]',
@@ -386,14 +370,13 @@ function AIConfirmingBanner({ fix, onAcceptAIProposal, onRejectAIProposal }: Pro
function NudgeBanner({ fix, onOutcome, onSilenceNudge }: ProposalBannerProps) {
return (
<div className="relative border-t border-warning/30 bg-warning-dim/60 px-5 py-2 flex items-center gap-3">
<div className="absolute left-0 top-0 bottom-0 w-[3px] bg-warning" />
<div className="border-t border-warning/30 bg-card px-5 py-2 flex items-center gap-3">
<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" />
<path d="M12 8v4" />
<path d="M12 16h.01" />
</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?
</span>
<button
@@ -407,7 +390,7 @@ function NudgeBanner({ fix, onOutcome, onSilenceNudge }: ProposalBannerProps) {
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} />
Still checking
@@ -417,13 +400,13 @@ function NudgeBanner({ fix, onOutcome, onSilenceNudge }: ProposalBannerProps) {
const reason = window.prompt("Why didn't it work? (optional)")
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
</button>
<button
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
</button>
@@ -435,15 +418,14 @@ function CollapsedBanner({ fix, onToggleCollapsed }: ProposalBannerProps) {
return (
<button
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" />
<span className="flex-1 text-[12px] 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="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-[0.625rem] font-bold tabular-nums">
{fix.confidence_pct}%
</span>
<span className="text-muted-foreground text-[11px]"> expand</span>
<span className="text-muted-foreground text-[0.6875rem]"> expand</span>
</button>
)
}

View File

@@ -80,10 +80,27 @@ function tokenize(body: string, highlightValues: Record<string, string> | undefi
while (cursor < seg.text.length) {
let matched: { key: string; value: string } | null = null
for (const [key, value] of valueEntries) {
if (seg.text.startsWith(value, cursor)) {
matched = { key, value }
break
}
if (!seg.text.startsWith(value, cursor)) continue
// Word-boundary guard: a single-char value like "D" (drive letter)
// 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) {
flushPending()

View File

@@ -39,7 +39,7 @@ export function AddNoteButton({ onAdd }: AddNoteButtonProps) {
return (
<button
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} />
Add a note
@@ -62,20 +62,20 @@ export function AddNoteButton({ onAdd }: AddNoteButtonProps) {
value={summary}
onChange={(e) => setSummary(e.target.value)}
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">
<button
onClick={handleSubmit}
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
</button>
<button
onClick={reset}
disabled={busy}
className="text-[0.75rem] text-muted-foreground hover:text-heading"
className="text-xs text-muted-foreground hover:text-heading"
>
Cancel
</button>

View File

@@ -11,8 +11,8 @@
* and renders the section. Loading/refresh logic lives in the parent
* (AssistantChatPage) so it can coordinate with the chat send cycle.
*/
import { Loader2 } from 'lucide-react'
import { cn } from '@/lib/utils'
import { useState } from 'react'
import { ChevronDown, ChevronRight, Loader2 } from 'lucide-react'
import type { SessionFact } from '@/api/sessionFacts'
import { WhatWeKnowItem } from './WhatWeKnowItem'
import { AddNoteButton } from './AddNoteButton'
@@ -23,32 +23,65 @@ interface WhatWeKnowProps {
onUpdateFact: (factId: string, text: string, summary: string | null) => Promise<void> | void
onDeleteFact: (factId: string) => Promise<void> | void
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({
facts,
onAddNote,
onUpdateFact,
onDeleteFact,
loading,
sessionId,
}: WhatWeKnowProps) {
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 (
<section
className={cn(
'rounded-lg p-3 -mx-1 mb-1',
// Subtle green-to-transparent gradient distinguishes this section
// from the rest of the lane (mockup 01-session-primary.png).
'bg-gradient-to-b from-success/[0.05] to-transparent',
)}
>
<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">
<span className="w-1.5 h-1.5 rounded-full bg-success" />
What we know
<span className="text-muted-foreground">·</span>
<span className="tabular-nums">{count}</span>
<section className="rounded-lg p-3 -mx-1 mb-1">
<div className={collapsed ? '' : 'pb-2'}>
<div className="flex items-center gap-2 pl-0.5">
<button
type="button"
onClick={toggle}
aria-expanded={!collapsed}
aria-label={collapsed ? 'Expand What we know' : 'Collapse What we know'}
className="flex items-center gap-2 text-[0.625rem] font-semibold uppercase tracking-[1.2px] text-muted-foreground hover:text-heading transition-colors"
>
{collapsed ? <ChevronRight size={10} /> : <ChevronDown size={10} />}
<span className="w-1.5 h-1.5 rounded-full bg-success" />
What we know
<span className="text-muted-foreground">·</span>
<span className="tabular-nums">{count}</span>
</button>
{loading && (
<span
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>
{count === 0 && loading && (
<div className="space-y-2 px-1 py-2">
<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>
{!collapsed && (
<>
{count === 0 && loading && (
<div className="space-y-2 px-1 py-2">
<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>
)
}

View File

@@ -79,20 +79,20 @@ export function WhatWeKnowItem({ fact, onSave, onDelete }: WhatWeKnowItemProps)
value={draftSummary}
onChange={(e) => setDraftSummary(e.target.value)}
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">
<button
onClick={handleSave}
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
</button>
<button
onClick={handleCancel}
disabled={busy}
className="text-[0.75rem] text-muted-foreground hover:text-heading"
className="text-xs text-muted-foreground hover:text-heading"
>
Cancel
</button>
@@ -104,7 +104,7 @@ export function WhatWeKnowItem({ fact, onSave, onDelete }: WhatWeKnowItemProps)
return (
<div
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',
)}
>

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 { getBranding, updateBranding, deleteLogo } 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 fileInputRef = useRef<HTMLInputElement>(null)
useEffect(() => {
loadBranding()
}, [teamId])
const loadBranding = async () => {
const loadBranding = useCallback(async () => {
setIsLoading(true)
try {
const data = await getBranding(teamId)
@@ -44,7 +40,11 @@ export function BrandingSettings({ teamId }: BrandingSettingsProps) {
} finally {
setIsLoading(false)
}
}
}, [teamId])
useEffect(() => {
loadBranding()
}, [loadBranding])
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]

View File

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

View File

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

View File

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

View File

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

View File

@@ -5,7 +5,7 @@ import { handoffsApi } from '@/api/handoffs'
import { timeAgo } from '@/lib/timeAgo'
import type { HandoffResponse } from '@/types/branching'
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 { uploadsApi } from '@/api/uploads'
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
// X also hides. Per-session lifetime — a refresh wipes the state, which is
// 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 [activeChatId, setActiveChatId] = useState<string | null>(() => {
if (urlSessionId) return urlSessionId
@@ -267,6 +264,15 @@ export default function AssistantChatPage() {
// path: post-claim the chat surface had no messages and the senior
// landed on a blank pane).
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
useEffect(() => {
@@ -612,7 +618,7 @@ export default function AssistantChatPage() {
}
window.addEventListener(PILOT_INLINE_SCRIPT_EVENT, handler as EventListener)
return () => window.removeEventListener(PILOT_INLINE_SCRIPT_EVENT, handler as EventListener)
}, [activeFix])
}, [activeFix, activeChatId])
const loadChats = async () => {
try {
@@ -684,7 +690,7 @@ export default function AssistantChatPage() {
try {
const list = await sessionFactsApi.list(chatId)
// Guard: discard stale fetch if the user switched chats mid-flight.
if (currentChatRef.current !== chatId) return
if (!guardCurrentChat(chatId, 'refreshFacts')) return
setFacts(list)
// 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
@@ -699,7 +705,7 @@ export default function AssistantChatPage() {
// Best-effort — facts are accessory state. Surfacing a toast on every
// refetch failure would be noisy; the empty state explains the absence.
}
}, [])
}, [guardCurrentChat])
// Phase 3 — active suggested fix + resolution-note preview.
// Declared BEFORE refreshSessionDerived / handleAddNote so the useCallback
@@ -707,7 +713,7 @@ export default function AssistantChatPage() {
const refreshActiveFix = useCallback(async (chatId: string) => {
try {
const fix = await sessionSuggestedFixesApi.getActive(chatId)
if (currentChatRef.current !== chatId) return
if (!guardCurrentChat(chatId, 'refreshActiveFix')) return
setActiveFix((prev) => {
// If the active fix changed (AI emitted a new SUGGEST_FIX that
// 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
// failures stay silent — accessory state, not load-bearing.
}
}, [])
}, [guardCurrentChat])
// Kind-aware preview fetch: Resolve hits /resolution-note/preview,
// Escalate hits /escalation-package/preview. They're cached separately
@@ -733,7 +739,7 @@ export default function AssistantChatPage() {
const p = effectiveKind === 'resolve'
? await sessionSuggestedFixesApi.getResolutionNotePreview(chatId)
: await sessionSuggestedFixesApi.getEscalationPackagePreview(chatId)
if (currentChatRef.current !== chatId) return
if (!guardCurrentChat(chatId, 'refreshPreview')) return
setPreviewData(p)
} catch (err: unknown) {
const status = (err as { response?: { status?: number } })?.response?.status
@@ -745,7 +751,7 @@ export default function AssistantChatPage() {
} finally {
setPreviewLoading(false)
}
}, [previewKind])
}, [guardCurrentChat, previewKind])
// Trigger preview refresh with a 500ms debounce. The backend cache short-
// 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.
setChatTab('script_builder')
}, [activeFix])
}, [activeFix, activeChatId])
// Phase 9 Task 13: TemplateMatchPanel "I ran this" — stamps applied_at so the
// ProposalBanner transitions from Proposed to Verifying. Shared useCallback so
@@ -903,6 +909,10 @@ export default function AssistantChatPage() {
try {
const updated = await sessionSuggestedFixesApi.patchOutcome(activeChatId, activeFix.id, outcome, notes)
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.
setPostApplyMsgCount(0)
setNudgeSilenced(false)
@@ -1108,13 +1118,13 @@ export default function AssistantChatPage() {
// 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
// clobber the new session's task lane state.
if (currentChatRef.current !== chatId) return
if (!guardCurrentChat(chatId, 'selectChat')) return
setActiveSessionStatus(detail.status)
setActivePsaTicketId(detail.psa_ticket_id)
if (detail.psa_ticket_id) {
integrationsApi.getTicket(detail.psa_ticket_id)
.then(ticket => {
if (currentChatRef.current !== chatId) return
if (!guardCurrentChat(chatId, 'selectChat.ticket')) return
setLinkedTicket(ticket)
})
.catch(() => {})
@@ -1149,7 +1159,7 @@ export default function AssistantChatPage() {
} catch {
setMessages([])
}
}, [refreshSessionDerived])
}, [guardCurrentChat, refreshSessionDerived, resetSessionDerivedState])
const handleAIAnalysis = useCallback(async () => {
if (!urlSessionId || !magicHandoff) return
@@ -1162,7 +1172,7 @@ export default function AssistantChatPage() {
setMagicState('dismissed')
void loadChats()
await selectChat(urlSessionId)
if (currentChatRef.current !== sentForChatId) return
if (!guardCurrentChat(sentForChatId, 'handleAIAnalysis.afterSelect')) return
const assessment = magicHandoff.ai_assessment_data
const snapshot = magicHandoff.snapshot as Record<string, unknown>
@@ -1192,7 +1202,7 @@ export default function AssistantChatPage() {
setMessages(prev => [...prev, { role: 'user', content: briefing }])
setLoading(true)
const response = await aiSessionsApi.sendChatMessage(urlSessionId, { message: briefing })
if (currentChatRef.current !== sentForChatId) return
if (!guardCurrentChat(sentForChatId, 'handleAIAnalysis.chatResponse')) return
setMessages(prev => [
...prev,
{
@@ -1233,7 +1243,7 @@ export default function AssistantChatPage() {
setActiveOptionKey(null)
setLoading(false)
}
}, [urlSessionId, magicHandoff, setSearchParams, selectChat])
}, [guardCurrentChat, urlSessionId, magicHandoff, setSearchParams, selectChat])
const handleNewChat = async () => {
// Invalidate currentChatRef BEFORE the await so any in-flight handleSend/handleTaskSubmit
@@ -1295,7 +1305,6 @@ export default function AssistantChatPage() {
.map((u) => u.preview)
setInput('')
setPendingUploads([])
setChipsHidden(true)
setMessages(prev => [...prev, { role: 'user', content: userMessage, imageUrls: imageUrls.length > 0 ? imageUrls : undefined }])
setLoading(true)
@@ -1306,7 +1315,7 @@ export default function AssistantChatPage() {
upload_ids: completedUploadIds.length > 0 ? completedUploadIds : undefined,
})
// 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' })
setMessages(prev => [
...prev,
@@ -1396,7 +1405,7 @@ export default function AssistantChatPage() {
try {
const response = await aiSessionsApi.sendChatMessage(activeChatId, { message: userMessage })
// 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 => [
...prev,
{ 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 })
// 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 => [
...prev,
{ 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>
{/* 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">
{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 && (
<>
<button
@@ -1793,55 +1785,76 @@ export default function AssistantChatPage() {
Resolve
</button>
<div className="relative">
<button
onClick={handleEscalateClick}
disabled={!canAct}
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"
>
<ArrowUpRight size={13} />
Escalate
</button>
{escalateIntercept && (
<EscalateInterceptDialog
fixTitle={escalateIntercept.fixTitle}
onChoose={handleInterceptChoice}
onClose={() => setEscalateIntercept(null)}
/>
)}
<button
onClick={handleEscalateClick}
disabled={!canAct}
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"
>
<ArrowUpRight size={13} />
Escalate
</button>
{escalateIntercept && (
<EscalateInterceptDialog
fixTitle={escalateIntercept.fixTitle}
onChoose={handleInterceptChoice}
onClose={() => setEscalateIntercept(null)}
/>
)}
</div>
</>
)}
{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 && (
{(magicHandoff || activePsaTicketId || messages.length >= 2) && (
<div className="relative">
<button
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"
aria-label="More session actions"
>
<MoreHorizontal size={16} />
</button>
{showOverflow && (
<>
<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">
<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 className="absolute right-0 top-full mt-1 z-50 w-44 rounded-lg border border-border bg-card py-1 shadow-lg">
{magicHandoff && (
<button
onClick={() => { setShowOverflow(false); openHandoffContextOverlay() }}
disabled={overlayLoading}
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"
>
<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>
</>
)}
@@ -1849,12 +1862,14 @@ export default function AssistantChatPage() {
)}
</div>
{/* Mobile: single overflow menu */}
{messages.length >= 2 && (
{/* Mobile: single overflow menu — same items as desktop kebab plus
Resolve/Escalate (which live in the visible row on desktop). */}
{(magicHandoff || activePsaTicketId || messages.length >= 2) && (
<div className="sm:hidden relative">
<button
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"
aria-label="Session actions"
>
<MoreHorizontal size={18} />
</button>
@@ -1862,7 +1877,7 @@ export default function AssistantChatPage() {
<>
<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">
{isActive && (
{isActive && messages.length >= 2 && (
<>
<button
onClick={() => { setShowOverflow(false); handleResolveClick() }}
@@ -1893,15 +1908,36 @@ export default function AssistantChatPage() {
</div>
</>
)}
<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 && (
{magicHandoff && (
<button
onClick={() => { setShowOverflow(false); openHandoffContextOverlay() }}
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"
>
<Sparkles size={14} />
Context
</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
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"
@@ -1932,8 +1968,11 @@ export default function AssistantChatPage() {
Hidden (not unmounted) when Script Builder tab is active so
scroll position and input state are preserved. */}
<div className={cn('flex-1 min-h-0 flex flex-col', chatTab !== 'chat' && 'hidden')}>
{/* Messages */}
<div className="flex-1 overflow-y-auto px-4 sm:px-6 py-4 space-y-4">
{/* Messages — scroll container is full width (so the scrollbar lives at
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 && (
<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">
@@ -1948,26 +1987,41 @@ export default function AssistantChatPage() {
</p>
</div>
)}
{messages.map((msg, i) => (
<ChatMessage
key={i}
role={msg.role}
content={msg.content}
suggestedFlows={msg.suggestedFlows}
imageUrls={msg.imageUrls}
/>
))}
{(() => {
// Action emphasis is shown on the *current* turn only — i.e. the
// latest assistant message when active items are pending and the
// magic-moment hero has dismissed. The TaskLane remains the
// canonical list; this is just an inline cue.
let lastAssistantIdx = -1
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 && (
<div className="flex gap-3">
<div className="w-8 h-8 rounded-full bg-primary/15 flex items-center justify-center">
<Sparkles size={14} className="text-primary" />
</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" />
</div>
</div>
)}
<div ref={messagesEndRef} />
</div>
</div>
{/* Phase 8: ProposalBanner — mounted above the composer */}
@@ -1988,8 +2042,9 @@ export default function AssistantChatPage() {
{/* Phase 9: InlineNoTemplateDialog — drafted-script evaluation case,
rendered in the chat region above the composer so all three
option cards fit side-by-side without the TaskLane's narrow width. */}
{scriptPanelOpen && activeFix && activeChatId && !activeFix.script_template_id && activeFix.ai_drafted_script && (
option cards fit side-by-side without the TaskLane's narrow width.
Hidden when the banner is collapsed: the two surfaces are linked. */}
{scriptPanelOpen && !bannerCollapsed && activeFix && activeChatId && !activeFix.script_template_id && activeFix.ai_drafted_script && (
<InlineNoTemplateDialog
fix={activeFix}
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 */}
<div className="px-3 sm:px-6 py-3 shrink-0">
<div
@@ -2182,7 +2100,7 @@ export default function AssistantChatPage() {
{upload.preview ? (
<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()}
</div>
)}
@@ -2210,7 +2128,7 @@ export default function AssistantChatPage() {
{showLogs && (
<div className="px-4 pb-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>
</div>
<textarea
@@ -2350,6 +2268,8 @@ export default function AssistantChatPage() {
loading={loading}
whatWeKnowSlot={
<WhatWeKnow
key={activeChatId ?? 'no-session'}
sessionId={activeChatId}
facts={facts}
onAddNote={handleAddNote}
onUpdateFact={handleUpdateFact}
@@ -2359,7 +2279,7 @@ export default function AssistantChatPage() {
}
bottomSlot={
<>
{scriptPanelOpen && activeFix && activeChatId && activeFix.script_template_id && (
{scriptPanelOpen && !bannerCollapsed && activeFix && activeChatId && activeFix.script_template_id && (
<TemplateMatchPanel
fix={activeFix}
sessionId={activeChatId}
@@ -2371,7 +2291,7 @@ export default function AssistantChatPage() {
<button
onClick={() => handleOpenPreview('resolve')}
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'
? 'text-success'
: 'text-accent-text hover:text-heading',
@@ -2383,7 +2303,7 @@ export default function AssistantChatPage() {
<button
onClick={() => handleOpenPreview('escalate')}
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'
? 'text-warning'
: 'text-muted-foreground hover:text-heading',
@@ -2421,6 +2341,8 @@ export default function AssistantChatPage() {
loading={loading}
whatWeKnowSlot={
<WhatWeKnow
key={activeChatId ?? 'no-session'}
sessionId={activeChatId}
facts={facts}
onAddNote={handleAddNote}
onUpdateFact={handleUpdateFact}
@@ -2430,7 +2352,7 @@ export default function AssistantChatPage() {
}
bottomSlot={
<>
{scriptPanelOpen && activeFix && activeChatId && activeFix.script_template_id && (
{scriptPanelOpen && !bannerCollapsed && activeFix && activeChatId && activeFix.script_template_id && (
<TemplateMatchPanel
fix={activeFix}
sessionId={activeChatId}
@@ -2442,7 +2364,7 @@ export default function AssistantChatPage() {
<button
onClick={() => handleOpenPreview('resolve')}
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'
? 'text-success'
: 'text-accent-text hover:text-heading',
@@ -2454,7 +2376,7 @@ export default function AssistantChatPage() {
<button
onClick={() => handleOpenPreview('escalate')}
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'
? 'text-warning'
: 'text-muted-foreground hover:text-heading',
@@ -2552,7 +2474,7 @@ export default function AssistantChatPage() {
{/* Handoff context overlay — re-opened from the toolbar */}
{overlayHandoff && (
<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) => {
if (e.target === e.currentTarget) setOverlayHandoff(null)
}}

View File

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

View File

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

View File

@@ -155,7 +155,7 @@ export function ProceduralNavigationPage() {
return () => {
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
useEffect(() => {

View File

@@ -57,7 +57,7 @@ export function SessionDetailPage() {
if (id) {
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
useEffect(() => {

View File

@@ -269,7 +269,7 @@ export default function SessionHistoryPage() {
<PageMeta title="Sessions" />
<div className="container mx-auto px-4 py-6 sm:px-6 sm:py-8">
{/* 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>
<p className="mt-1 text-sm text-muted-foreground">View and manage your sessions</p>
</div>
@@ -279,6 +279,7 @@ export default function SessionHistoryPage() {
{TABS.map((tab) => (
<button
key={tab.id}
data-testid={`session-history-tab-${tab.id}`}
onClick={() => setActiveTab(tab.id)}
className={cn(
'px-4 py-2 text-sm transition-colors whitespace-nowrap',
@@ -614,6 +615,7 @@ export default function SessionHistoryPage() {
Close
</button>
<button
data-testid="flow-session-resume"
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"
>

View File

@@ -234,7 +234,7 @@ export function TreeEditorPage() {
return () => {
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
useEffect(() => {
@@ -391,7 +391,7 @@ export function TreeEditorPage() {
} finally {
setSaving(false)
}
}, [isSaving, isEditMode, id, editorMode, getTreeForSave, markSaved, navigate])
}, [isSaving, isEditMode, id, editorMode, getTreeForSave, markSaved, navigate, setSaving])
const handlePublish = useCallback(async () => {
if (isSaving) return
@@ -472,7 +472,7 @@ export function TreeEditorPage() {
} finally {
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)
const handleSave = useCallback(async () => {

View File

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