Merge pull request 'feat(session): impeccable pass + tasklane keyboard flow' (#158) from feat/session-distill-quieter into main
Reviewed-on: #158 -Michael Chihlas
This commit was merged in pull request #158.
This commit is contained in:
@@ -2,32 +2,24 @@
|
||||
|
||||
# HANDOFF.md
|
||||
|
||||
**Last updated:** 2026-05-01 (session 6 — PR #156 QA'd, merged, branch deleted)
|
||||
**Last updated:** 2026-05-01 (session 9 — started 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)
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
81
docs/plans/2026-05-01-issue-cleanup-plan.md
Normal file
81
docs/plans/2026-05-01-issue-cleanup-plan.md
Normal 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.
|
||||
@@ -16,7 +16,7 @@ function App() {
|
||||
} else {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [])
|
||||
}, [fetchUser, isAuthenticated, setLoading])
|
||||
|
||||
return <RouterProvider router={router} />
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 => (
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -261,7 +261,7 @@ export function TreeCanvas() {
|
||||
})
|
||||
setExpandedNodeId(null)
|
||||
},
|
||||
[pendingLinks, treeStructure, updateNode]
|
||||
[addNode, pendingLinks, treeStructure, updateNode]
|
||||
)
|
||||
|
||||
// ── Cancel new node ──
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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"
|
||||
>
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
Reference in New Issue
Block a user