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
|
# 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.
|
**Active task:** None. Pick next from `.ai/TODO.md` or roadmap.
|
||||||
|
|
||||||
**Just-merged:** PR #156 (suggested-fix `applied_pending` non-terminal outcome) merged into `main` as `3ba4532`.
|
**Just-updated:** issue cleanup plan sections 1 and 2 were started and documented.
|
||||||
|
|
||||||
## Where this session ended
|
## Where this session ended
|
||||||
|
|
||||||
PR #156 QA'd in the dev environment and merged.
|
Issue cleanup plan follow-up completed:
|
||||||
|
|
||||||
1. Working tree had two commits' worth of pending work: the prior session's local review fixes (5 source files + 3 `.ai/` notes describing them) and this session's docker-exec docs (`.ai/PROJECT_CONTEXT.md` + `AGENTS.md`). Committed each as a separate logical commit, attributed to the agent that authored each.
|
- Section 1: frontend lint is clean. Stale lint disables from the warning set were removed or replaced with justified comments, hook dependency warnings were resolved, e2e selectors were added for session history and the FlowPilot command-palette entry, and `AssistantChatPage` now logs unexpected `currentChatRef` stale async discards.
|
||||||
2. Browser QA via `/qa`: 5 of 7 scripted checks PASS with concrete DB-level + UI-level evidence — PendingBanner rendering, "It worked" / "Update reason" / "Dismiss" actions, page-level Resolve auto-patch, Escalate intercept with new generalized copy. 2 entry-path checks (VerifyingBanner overflow → "Waiting to verify…", nudge "Still checking") deferred because they require live AI-generated chat state. The mutating handlers behind those entry paths are verified via the tested transitions, so risk is rendering-only.
|
- Section 2: `TaskLane` action cards now have diagnostic help affordances for common commands (connectivity, DNS, IP config, event logs, services, and generic checks). #128 was documented as "keep existing responsive side-panel/bottom-drawer behavior unless pilot feedback proves a preference is needed."
|
||||||
3. Pushed `feat/fix-pending-verification` to remote. Required Gitea CI checks (`CI / frontend`, `CI / backend`) plus `CI / e2e` all green at merge. Merged via Gitea API as a merge commit (`3ba4532`).
|
- Updated `docs/plans/2026-05-01-issue-cleanup-plan.md` with section 1/2 status and validation.
|
||||||
4. Local `main` fast-forwarded to remote; `feat/fix-pending-verification` deleted locally and on the remote.
|
- Validation passed: `docker exec -w /app resolutionflow_frontend npm run lint`, `docker exec -w /app resolutionflow_frontend npx tsc -b`, and `docker exec -w /app resolutionflow_frontend npm run build` (existing Vite large-chunk warning only).
|
||||||
|
|
||||||
**Validation evidence:**
|
|
||||||
|
|
||||||
- `/gstack/qa-reports/qa-report-pending-verification-2026-04-30.md` — full report with screenshots in `screenshots/`.
|
|
||||||
- Gitea PR #156 state: `closed`, `merge_commit_sha=3ba45326`, `merged_at=2026-05-01T03:42:10Z`.
|
|
||||||
|
|
||||||
## Resume point — DO THIS NEXT
|
## Resume point — DO THIS NEXT
|
||||||
|
|
||||||
Pick a task from `.ai/TODO.md` or `03-DEVELOPMENT-ROADMAP.md`. Two non-blocking follow-ups for the just-shipped feature:
|
If tracker auth is available, close #127 and close/archive stale PR #124; rewrite #66 to template packs / one-click install only. Then continue the plan at section 3: #58 structured "step is wrong" quality signals. After that, section 4 is #60 recurring issue detection and section 5 is #129 hierarchical guide navigation.
|
||||||
|
|
||||||
- Drive checks 1 and 5 from the QA report in real pilot usage to close the entry-path UI rendering gap.
|
|
||||||
- Watch whether engineers lose track of multiple pending fixes across sessions; if so, revisit the cross-session "Follow-ups" rollup that was scoped out of PR #156.
|
|
||||||
|
|
||||||
## Environment notes (carry-forward)
|
## Environment notes (carry-forward)
|
||||||
|
|
||||||
|
|||||||
@@ -12,6 +12,33 @@
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## 2026-05-01 07:20 UTC — Codex — Start issue cleanup plan sections 1 and 2
|
||||||
|
|
||||||
|
- Started `docs/plans/2026-05-01-issue-cleanup-plan.md` sections 1 and 2.
|
||||||
|
- Cleaned frontend lint to zero warnings by removing stale lint disables, tightening hook dependencies, and adding justified comments where effects are intentionally keyed to route or owner identity.
|
||||||
|
- Added e2e selectors for session history controls and the FlowPilot command-palette entry.
|
||||||
|
- Added `AssistantChatPage` observability for unexpected `currentChatRef` stale async discards.
|
||||||
|
- Added `TaskLane` diagnostic help affordances for common command categories and documented #128 as "keep the existing responsive side-panel/bottom-drawer behavior until pilot feedback says otherwise."
|
||||||
|
- Verified `npm run lint`, `npx tsc -b`, and `npm run build` in `resolutionflow_frontend`; build only reported the existing Vite large-chunk warning.
|
||||||
|
- Files touched: frontend lint-cleanup files, `frontend/src/components/assistant/TaskLane.tsx`, `frontend/src/pages/AssistantChatPage.tsx`, `frontend/src/pages/SessionHistoryPage.tsx`, `frontend/src/components/layout/CommandPalette.tsx`, `docs/plans/2026-05-01-issue-cleanup-plan.md`, `.ai/HANDOFF.md`, `.ai/SESSION_LOG.md`.
|
||||||
|
|
||||||
|
## 2026-05-01 06:05 UTC — Codex — Clean stale TODOs and add issue cleanup plan
|
||||||
|
|
||||||
|
- Removed the resolved pytest-xdist item from `.ai/TODO.md` and reset "Up next" to no selected task.
|
||||||
|
- Removed the resolved "Add role gate to handoff claim endpoint" backlog item from `.ai/TODO.md`.
|
||||||
|
- Updated the frontend lint cleanup TODO from 23 warnings to the current `npm run lint` result: 24 warnings, 0 errors.
|
||||||
|
- Tried to close Gitea #127 through the API, but this environment has no Gitea token; API returned `401 token is required`.
|
||||||
|
- Added `docs/plans/2026-05-01-issue-cleanup-plan.md` with safe tracker actions and a recommended order for clearing remaining issues.
|
||||||
|
- Files touched: `.ai/TODO.md`, `.ai/HANDOFF.md`, `.ai/SESSION_LOG.md`, `docs/plans/2026-05-01-issue-cleanup-plan.md`.
|
||||||
|
|
||||||
|
## 2026-05-01 05:40 UTC — Codex — Audit TODO backlog and Gitea issue validity
|
||||||
|
|
||||||
|
- Compared `.ai/TODO.md`, inline code TODOs, and open Gitea issues against current `main`.
|
||||||
|
- Verified pytest-xdist is already shipped (`backend/requirements-dev.txt`, `backend/tests/conftest.py`, `.gitea/workflows/ci.yml`) so the `.ai/TODO.md` xdist item is stale. Ran frontend lint in Docker; current state is `0 errors, 24 warnings`, so the lint cleanup item remains valid but its count is stale.
|
||||||
|
- Verified Gitea issue status: #58, #60, #128, #129, #130 remain valid; #66 is partially resolved by current `.rfflow` import/export and should be narrowed to template packs/marketplace; #127 is mostly resolved by current UI copy and prompt boundaries unless an always-visible scope badge is still wanted. Open PR #124 is stale/unmergeable against current `main`.
|
||||||
|
- Verified inline TODOs still valid: post-session contextual feedback prompt, FlowPilot analytics domain/time-entry placeholders, prompt-cache verification note unless live telemetry has confirmed it, proposal `modify` flow editor wiring, and procedural ghost-step accept/dismiss buttons.
|
||||||
|
- Files touched: `.ai/HANDOFF.md`, `.ai/SESSION_LOG.md`.
|
||||||
|
|
||||||
## 2026-05-01 03:45 UTC — Claude Opus 4.7 — QA, merge, and ship PR #156 pending-verification
|
## 2026-05-01 03:45 UTC — Claude Opus 4.7 — QA, merge, and ship PR #156 pending-verification
|
||||||
|
|
||||||
- Committed two logical units of pending work on `feat/fix-pending-verification`: prior session's local review fixes as `5bee264` (Codex-attributed, 5 source files + 3 `.ai/` notes) and this session's docker-exec docs as `15042af` (Claude-attributed, `.ai/PROJECT_CONTEXT.md` + `AGENTS.md`). Cleaned up a 20MB `core.22120` Chromium dump left behind by an earlier sandbox crash.
|
- Committed two logical units of pending work on `feat/fix-pending-verification`: prior session's local review fixes as `5bee264` (Codex-attributed, 5 source files + 3 `.ai/` notes) and this session's docker-exec docs as `15042af` (Claude-attributed, `.ai/PROJECT_CONTEXT.md` + `AGENTS.md`). Cleaned up a 20MB `core.22120` Chromium dump left behind by an earlier sandbox crash.
|
||||||
|
|||||||
@@ -5,11 +5,11 @@
|
|||||||
|
|
||||||
## Up next
|
## Up next
|
||||||
|
|
||||||
- [ ] **Parallelize backend pytest with pytest-xdist.** ✅ landing as PR #151. Verified locally: backend suite 22 min → 4m 28s with `-n auto` on the 8-core homelab runner. Per-worker DB isolation via `PYTEST_XDIST_WORKER` in conftest.py.
|
None selected. Pick from the backlog below or `03-DEVELOPMENT-ROADMAP.md`.
|
||||||
|
|
||||||
## Backlog
|
## Backlog
|
||||||
|
|
||||||
- [ ] **Frontend lint warnings cleanup.** 23 `react-hooks/exhaustive-deps` warnings remain after PR #149 (mostly missing-deps in useEffect). Either fix them or audit them for known-safe ones and add eslint-disable comments. Not blocking CI today.
|
- [ ] **Frontend lint warnings cleanup.** `npm run lint` currently reports 24 warnings (0 errors): mostly `react-hooks/exhaustive-deps` plus a few unused eslint-disable directives. Either fix them or audit known-safe ones and add/remove eslint-disable comments intentionally. Not blocking CI today.
|
||||||
- [ ] **Audit `filterwarnings` ignores added in `wip(handoff): restore backend suite to green`.** Codex added narrow `ResourceWarning` filters for unclosed socket/transport/event-loop noise from pytest-asyncio teardown. Worth periodically reviewing whether those are still needed (e.g. when bumping pytest-asyncio) — if a real warning appears in those forms it would be silenced.
|
- [ ] **Audit `filterwarnings` ignores added in `wip(handoff): restore backend suite to green`.** Codex added narrow `ResourceWarning` filters for unclosed socket/transport/event-loop noise from pytest-asyncio teardown. Worth periodically reviewing whether those are still needed (e.g. when bumping pytest-asyncio) — if a real warning appears in those forms it would be silenced.
|
||||||
- [ ] **Add `data-testid` attributes to e2e-critical interactive elements.** PR #152 fixed five Playwright tests by chasing UI-text changes (`Sessions` → `Session History`, `Account Settings` → `Account Management`, `/assistant` → `/pilot`, "Flow Sessions" tab, Resume button on session cards). Each was a one-line selector update, but every UI churn re-breaks them. Adding stable `data-testid` attributes on the targeted elements (page heading wrappers, tab nav, primary action buttons) and switching tests to `getByTestId` would make these immune to copy/route renames. Scope it small — start with `SessionHistoryPage` heading, the AI/Flow Sessions tab buttons, the per-session `Resume` button, and the command-palette FlowPilot option.
|
- [ ] **Add `data-testid` attributes to e2e-critical interactive elements.** PR #152 fixed five Playwright tests by chasing UI-text changes (`Sessions` → `Session History`, `Account Settings` → `Account Management`, `/assistant` → `/pilot`, "Flow Sessions" tab, Resume button on session cards). Each was a one-line selector update, but every UI churn re-breaks them. Adding stable `data-testid` attributes on the targeted elements (page heading wrappers, tab nav, primary action buttons) and switching tests to `getByTestId` would make these immune to copy/route renames. Scope it small — start with `SessionHistoryPage` heading, the AI/Flow Sessions tab buttons, the per-session `Resume` button, and the command-palette FlowPilot option.
|
||||||
- [ ] **Per-test transactional rollback in `test_db` fixture.** Bigger engineering than xdist (which we already shipped). Instead of `DROP SCHEMA public CASCADE` per test, wrap each test in a savepoint and rollback at teardown. ~30-40% additional speedup on top of xdist for test-DB-heavy tests. Real refactor; only worth it if the suite gets significantly larger or runs more frequently.
|
- [ ] **Per-test transactional rollback in `test_db` fixture.** Bigger engineering than xdist (which we already shipped). Instead of `DROP SCHEMA public CASCADE` per test, wrap each test in a savepoint and rollback at teardown. ~30-40% additional speedup on top of xdist for test-DB-heavy tests. Real refactor; only worth it if the suite gets significantly larger or runs more frequently.
|
||||||
@@ -20,4 +20,6 @@
|
|||||||
|
|
||||||
- [ ] **Mobile/responsive design for EscalationQueue + handoff-context screen.** Pre-PMF wedge demo targets desktop only — MSP techs work on laptops/desktops in shop environments. Once 3+ paying customers exist and a tech requests mobile (likely on-call use case), spec the responsive behavior: stacked card layout below `sm:` breakpoint, full-bleed handoff-context overlay on mobile, swipe-to-claim gesture instead of Pick Up button. Surfaced from /plan-design-review on the Escalation-Mode wedge plan.
|
- [ ] **Mobile/responsive design for EscalationQueue + handoff-context screen.** Pre-PMF wedge demo targets desktop only — MSP techs work on laptops/desktops in shop environments. Once 3+ paying customers exist and a tech requests mobile (likely on-call use case), spec the responsive behavior: stacked card layout below `sm:` breakpoint, full-bleed handoff-context overlay on mobile, swipe-to-claim gesture instead of Pick Up button. Surfaced from /plan-design-review on the Escalation-Mode wedge plan.
|
||||||
|
|
||||||
- [ ] **(MOVED IN-SCOPE for Escalation Mode v1, 2026-04-27)** ~~Add role gate to handoff claim endpoint.~~ Codex review correctly flagged this as wedge-relevant (the race-condition story depends on auth gating). Now part of the Escalation Mode v1 build, not a deferred TODO.
|
- [ ] **`bg-card-hover` Tailwind class doesn't resolve.** [`frontend/src/components/layout/CommandPalette.tsx:450-451`](../frontend/src/components/layout/CommandPalette.tsx) uses `bg-card-hover` as a Tailwind utility, but Tailwind v4 generates `bg-{token}` from `--color-{token}` — and the token in [`frontend/src/index.css:15`](../frontend/src/index.css) is `--color-bg-card-hover`, which generates `bg-bg-card-hover`, not `bg-card-hover`. So those classes silently produce nothing. Other call sites (KnowledgeBaseCards, TeamSummary, ProposalBanner) use the explicit `hover:bg-[var(--color-bg-card-hover)]` form which works. Fix: change the CommandPalette classes to the explicit-var form, OR add a `--color-card-hover` semantic mapping in index.css alongside `--color-card`. Surfaced 2026-05-01 during impeccable polish sweep.
|
||||||
|
|
||||||
|
- [ ] **`ConcludeSessionModal` paused/escalated step forces single-artifact choice — should allow multi-select.** [`frontend/src/components/assistant/ConcludeSessionModal.tsx`](../frontend/src/components/assistant/ConcludeSessionModal.tsx) ~lines 430-474 ("Paused/Escalated: status update options"). Today the engineer clicks ONE of Ticket Notes / Client Update / Email Draft, the buttons disappear, and the result replaces them. Real MSP escalations almost always need at least two: technical notes for the next engineer's PSA AND a non-technical client update. Same for pause (client update + ticket notes for context when resuming). Recommended shape: multi-select with smart defaults — three checkboxes (`☑ Ticket Notes ☑ Client Update ☐ Email Draft`); for `escalated` pre-check Ticket Notes + Client Update; for `paused` pre-check Client Update only. One "Generate" button fires all selected in parallel via existing `aiSessionsApi.generateStatusUpdate(...)` (already supports the three `audience` values: `ticket_notes`, `client_update`, `email_draft`). Each result renders in its own card with its own Copy / Post-to-PSA / Send-Email action. Surfaced 2026-05-01. Feature work, not polish — touches streaming wiring for parallel calls.
|
||||||
|
|||||||
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 {
|
} else {
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
}
|
}
|
||||||
}, [])
|
}, [fetchUser, isAuthenticated, setLoading])
|
||||||
|
|
||||||
return <RouterProvider router={router} />
|
return <RouterProvider router={router} />
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ export function FlowAnalyticsPanel({ treeId }: FlowAnalyticsPanelProps) {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
|
||||||
setError(false)
|
setError(false)
|
||||||
analyticsApi
|
analyticsApi
|
||||||
.getFlowAnalytics(treeId, period)
|
.getFlowAnalytics(treeId, period)
|
||||||
|
|||||||
@@ -74,7 +74,7 @@ export function ActionCardGroup({ actions, onSubmit, disabled, stale }: ActionCa
|
|||||||
onClick={() => setExpanded(true)}
|
onClick={() => setExpanded(true)}
|
||||||
className="w-full rounded-lg border border-default/50 bg-elevated/20 p-2.5 flex items-center justify-between text-left hover:bg-elevated/40 transition-colors group"
|
className="w-full rounded-lg border border-default/50 bg-elevated/20 p-2.5 flex items-center justify-between text-left hover:bg-elevated/40 transition-colors group"
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-2 text-[0.75rem] text-muted-foreground">
|
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||||
<Terminal size={12} />
|
<Terminal size={12} />
|
||||||
<span>{pendingCount} diagnostic check{pendingCount !== 1 ? 's' : ''} — not completed</span>
|
<span>{pendingCount} diagnostic check{pendingCount !== 1 ? 's' : ''} — not completed</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -95,7 +95,7 @@ export function ActionCardGroup({ actions, onSubmit, disabled, stale }: ActionCa
|
|||||||
</div>
|
</div>
|
||||||
<div className="space-y-0.5">
|
<div className="space-y-0.5">
|
||||||
{responses.map((r, i) => (
|
{responses.map((r, i) => (
|
||||||
<div key={i} className="flex items-center gap-2 text-[0.75rem] text-muted-foreground">
|
<div key={i} className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||||
{r.state === 'done' ? (
|
{r.state === 'done' ? (
|
||||||
<Check size={10} className="text-success shrink-0" />
|
<Check size={10} className="text-success shrink-0" />
|
||||||
) : (
|
) : (
|
||||||
@@ -118,7 +118,7 @@ export function ActionCardGroup({ actions, onSubmit, disabled, stale }: ActionCa
|
|||||||
<div>
|
<div>
|
||||||
<button
|
<button
|
||||||
onClick={() => setShowRunAll(!showRunAll)}
|
onClick={() => setShowRunAll(!showRunAll)}
|
||||||
className="flex items-center gap-1.5 text-[0.75rem] font-medium text-accent-text hover:text-accent transition-colors"
|
className="flex items-center gap-1.5 text-xs font-medium text-accent-text hover:text-accent transition-colors"
|
||||||
>
|
>
|
||||||
<Terminal size={12} />
|
<Terminal size={12} />
|
||||||
<span>Run All ({commandActions.length} commands)</span>
|
<span>Run All ({commandActions.length} commands)</span>
|
||||||
@@ -128,12 +128,12 @@ export function ActionCardGroup({ actions, onSubmit, disabled, stale }: ActionCa
|
|||||||
{showRunAll && (
|
{showRunAll && (
|
||||||
<div className="mt-2 rounded-lg border border-default bg-code p-3">
|
<div className="mt-2 rounded-lg border border-default bg-code p-3">
|
||||||
<div className="flex items-center justify-between mb-2">
|
<div className="flex items-center justify-between mb-2">
|
||||||
<span className="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">
|
<span className="text-[0.625rem] font-semibold uppercase tracking-wider text-muted-foreground">
|
||||||
Combined diagnostic script
|
Combined diagnostic script
|
||||||
</span>
|
</span>
|
||||||
<button
|
<button
|
||||||
onClick={() => handleCopyCommand(combinedScript)}
|
onClick={() => handleCopyCommand(combinedScript)}
|
||||||
className="flex items-center gap-1 text-[0.75rem] text-muted-foreground hover:text-heading transition-colors"
|
className="flex items-center gap-1 text-xs text-muted-foreground hover:text-heading transition-colors"
|
||||||
>
|
>
|
||||||
<Copy size={11} />
|
<Copy size={11} />
|
||||||
<span>Copy</span>
|
<span>Copy</span>
|
||||||
@@ -167,23 +167,23 @@ export function ActionCardGroup({ actions, onSubmit, disabled, stale }: ActionCa
|
|||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<div className="text-[0.8125rem] font-medium text-heading">{action.label}</div>
|
<div className="text-[0.8125rem] font-medium text-heading">{action.label}</div>
|
||||||
{action.description && (
|
{action.description && (
|
||||||
<div className="text-[0.75rem] text-muted-foreground mt-0.5">{action.description}</div>
|
<div className="text-xs text-muted-foreground mt-0.5">{action.description}</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Status badge for handled cards */}
|
{/* Status badge for handled cards */}
|
||||||
{response.state === 'done' && (
|
{response.state === 'done' && (
|
||||||
<span className="shrink-0 text-[10px] font-semibold uppercase tracking-wider text-success">Done</span>
|
<span className="shrink-0 text-[0.625rem] font-semibold uppercase tracking-wider text-success">Done</span>
|
||||||
)}
|
)}
|
||||||
{response.state === 'skipped' && (
|
{response.state === 'skipped' && (
|
||||||
<span className="shrink-0 text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">Skipped</span>
|
<span className="shrink-0 text-[0.625rem] font-semibold uppercase tracking-wider text-muted-foreground">Skipped</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Command with copy button */}
|
{/* Command with copy button */}
|
||||||
{action.command && response.state !== 'skipped' && (
|
{action.command && response.state !== 'skipped' && (
|
||||||
<div className="mt-2 flex items-center gap-2 rounded bg-code px-2.5 py-1.5">
|
<div className="mt-2 flex items-center gap-2 rounded bg-code px-2.5 py-1.5">
|
||||||
<code className="flex-1 text-[0.75rem] font-mono text-heading truncate">
|
<code className="flex-1 text-xs font-mono text-heading truncate">
|
||||||
{action.command}
|
{action.command}
|
||||||
</code>
|
</code>
|
||||||
<button
|
<button
|
||||||
@@ -201,20 +201,20 @@ export function ActionCardGroup({ actions, onSubmit, disabled, stale }: ActionCa
|
|||||||
<div className="mt-2 flex flex-col sm:flex-row items-stretch sm:items-center gap-2">
|
<div className="mt-2 flex flex-col sm:flex-row items-stretch sm:items-center gap-2">
|
||||||
<button
|
<button
|
||||||
onClick={() => updateCard(idx, { state: 'pasting' })}
|
onClick={() => updateCard(idx, { state: 'pasting' })}
|
||||||
className="flex items-center justify-center gap-1 rounded-md border border-accent/40 bg-accent-dim/30 px-2.5 py-2 sm:py-1 text-[0.75rem] font-medium text-accent-text hover:bg-accent-dim/50 transition-colors min-h-[44px] sm:min-h-0"
|
className="flex items-center justify-center gap-1 rounded-md border border-accent/40 bg-accent-dim/30 px-2.5 py-2 sm:py-1 text-xs font-medium text-accent-text hover:bg-accent-dim/50 transition-colors min-h-[44px] sm:min-h-0"
|
||||||
>
|
>
|
||||||
<Clipboard size={11} />
|
<Clipboard size={11} />
|
||||||
Paste Result
|
Paste Result
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => updateCard(idx, { state: 'typing' })}
|
onClick={() => updateCard(idx, { state: 'typing' })}
|
||||||
className="flex items-center justify-center gap-1 rounded-md border border-default bg-elevated/50 px-2.5 py-2 sm:py-1 text-[0.75rem] font-medium text-heading hover:bg-elevated transition-colors min-h-[44px] sm:min-h-0"
|
className="flex items-center justify-center gap-1 rounded-md border border-default bg-elevated/50 px-2.5 py-2 sm:py-1 text-xs font-medium text-heading hover:bg-elevated transition-colors min-h-[44px] sm:min-h-0"
|
||||||
>
|
>
|
||||||
Type Answer
|
Type Answer
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => updateCard(idx, { state: 'skipped' })}
|
onClick={() => updateCard(idx, { state: 'skipped' })}
|
||||||
className="flex items-center justify-center gap-1 rounded-md px-2.5 py-2 sm:py-1 text-[0.75rem] text-muted-foreground hover:text-heading transition-colors min-h-[44px] sm:min-h-0"
|
className="flex items-center justify-center gap-1 rounded-md px-2.5 py-2 sm:py-1 text-xs text-muted-foreground hover:text-heading transition-colors min-h-[44px] sm:min-h-0"
|
||||||
>
|
>
|
||||||
<SkipForward size={11} />
|
<SkipForward size={11} />
|
||||||
Skip
|
Skip
|
||||||
@@ -237,14 +237,14 @@ export function ActionCardGroup({ actions, onSubmit, disabled, stale }: ActionCa
|
|||||||
<button
|
<button
|
||||||
onClick={() => updateCard(idx, { state: 'done' })}
|
onClick={() => updateCard(idx, { state: 'done' })}
|
||||||
disabled={!response.value.trim()}
|
disabled={!response.value.trim()}
|
||||||
className="flex items-center gap-1 rounded-md bg-accent px-2.5 py-1 text-[0.75rem] font-medium text-white disabled:opacity-40 hover:bg-accent-hover transition-colors"
|
className="flex items-center gap-1 rounded-md bg-accent px-2.5 py-1 text-xs font-medium text-white disabled:opacity-40 hover:bg-accent-hover transition-colors"
|
||||||
>
|
>
|
||||||
<Check size={11} />
|
<Check size={11} />
|
||||||
Done
|
Done
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => updateCard(idx, { state: 'pending', value: '' })}
|
onClick={() => updateCard(idx, { state: 'pending', value: '' })}
|
||||||
className="text-[0.75rem] text-muted-foreground hover:text-heading transition-colors"
|
className="text-xs text-muted-foreground hover:text-heading transition-colors"
|
||||||
>
|
>
|
||||||
Cancel
|
Cancel
|
||||||
</button>
|
</button>
|
||||||
@@ -282,7 +282,7 @@ export function ActionCardGroup({ actions, onSubmit, disabled, stale }: ActionCa
|
|||||||
</button>
|
</button>
|
||||||
|
|
||||||
{submitError && (
|
{submitError && (
|
||||||
<div className="flex items-center gap-1.5 text-[0.75rem] text-danger">
|
<div className="flex items-center gap-1.5 text-xs text-danger">
|
||||||
<AlertCircle size={12} />
|
<AlertCircle size={12} />
|
||||||
<span>Failed to send</span>
|
<span>Failed to send</span>
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Sparkles, User } from 'lucide-react'
|
import { Sparkles, User, ListChecks } from 'lucide-react'
|
||||||
import { MarkdownContent } from '@/components/ui/MarkdownContent'
|
import { MarkdownContent } from '@/components/ui/MarkdownContent'
|
||||||
import { SuggestedFlowCard } from './SuggestedFlowCard'
|
import { SuggestedFlowCard } from './SuggestedFlowCard'
|
||||||
import type { SuggestedFlow } from '@/types/copilot'
|
import type { SuggestedFlow } from '@/types/copilot'
|
||||||
@@ -8,9 +8,14 @@ interface ChatMessageProps {
|
|||||||
content: string
|
content: string
|
||||||
suggestedFlows?: SuggestedFlow[]
|
suggestedFlows?: SuggestedFlow[]
|
||||||
imageUrls?: string[]
|
imageUrls?: string[]
|
||||||
|
/** When set on an assistant message, renders a leading "Next steps · N pending"
|
||||||
|
* emphasis above the bubble. Used on the current turn only — the canonical
|
||||||
|
* list of items lives in the TaskLane. */
|
||||||
|
actionCount?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ChatMessage({ role, content, suggestedFlows, imageUrls }: ChatMessageProps) {
|
export function ChatMessage({ role, content, suggestedFlows, imageUrls, actionCount }: ChatMessageProps) {
|
||||||
|
const hasActionEmphasis = role === 'assistant' && actionCount !== undefined && actionCount > 0
|
||||||
return (
|
return (
|
||||||
<div className={`flex gap-3 ${role === 'user' ? 'flex-row-reverse' : ''}`}>
|
<div className={`flex gap-3 ${role === 'user' ? 'flex-row-reverse' : ''}`}>
|
||||||
{/* Avatar */}
|
{/* Avatar */}
|
||||||
@@ -41,20 +46,32 @@ export function ChatMessage({ role, content, suggestedFlows, imageUrls }: ChatMe
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{hasActionEmphasis && (
|
||||||
|
<div className="flex items-center gap-1.5 text-xs font-medium text-heading">
|
||||||
|
<ListChecks size={12} className="text-primary" />
|
||||||
|
Next steps
|
||||||
|
<span className="text-muted-foreground font-normal">
|
||||||
|
· {actionCount} pending in Tasks
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div
|
<div
|
||||||
className={`rounded-2xl px-4 py-3 text-[0.875rem] leading-relaxed ${
|
className={`rounded-xl px-4 py-3 text-sm leading-relaxed ${
|
||||||
role === 'user'
|
role === 'user'
|
||||||
? 'bg-primary/15 text-foreground'
|
? 'bg-primary/15 text-foreground'
|
||||||
: 'bg-input text-foreground border border-border'
|
: hasActionEmphasis
|
||||||
|
? 'bg-input text-foreground border border-hover'
|
||||||
|
: 'bg-input text-foreground border border-border'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<MarkdownContent content={content} className="text-[0.875rem] leading-relaxed" />
|
<MarkdownContent content={content} className="text-sm leading-relaxed" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Suggested flows (assistant only) */}
|
{/* Suggested flows (assistant only) */}
|
||||||
{role === 'assistant' && suggestedFlows && suggestedFlows.length > 0 && (
|
{role === 'assistant' && suggestedFlows && suggestedFlows.length > 0 && (
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
<span className="font-sans text-xs text-[0.625rem] uppercase tracking-widest text-muted-foreground">
|
<span className="text-[0.625rem] uppercase tracking-widest text-muted-foreground">
|
||||||
Related Flows
|
Related Flows
|
||||||
</span>
|
</span>
|
||||||
{suggestedFlows.map(flow => (
|
{suggestedFlows.map(flow => (
|
||||||
|
|||||||
@@ -85,7 +85,7 @@ export function ChatSidebar({
|
|||||||
<div className="flex-1 overflow-y-auto py-2">
|
<div className="flex-1 overflow-y-auto py-2">
|
||||||
{pinnedChats.length > 0 && (
|
{pinnedChats.length > 0 && (
|
||||||
<div className="px-3 mb-1">
|
<div className="px-3 mb-1">
|
||||||
<span className="font-sans text-[0.625rem] uppercase tracking-widest text-muted-foreground">
|
<span className="text-[0.625rem] uppercase tracking-widest text-muted-foreground">
|
||||||
Pinned
|
Pinned
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -159,7 +159,7 @@ export function ChatSidebarCollapsedBar({
|
|||||||
<History size={14} />
|
<History size={14} />
|
||||||
<span>History</span>
|
<span>History</span>
|
||||||
{chats.length > 0 && (
|
{chats.length > 0 && (
|
||||||
<span className="text-[10px] bg-elevated rounded-full px-1.5 py-0.5 font-medium">{chats.length}</span>
|
<span className="text-[0.625rem] bg-elevated rounded-full px-1.5 py-0.5 font-medium">{chats.length}</span>
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
<div className="flex-1" />
|
<div className="flex-1" />
|
||||||
@@ -203,7 +203,7 @@ function ChatItem({
|
|||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
{confirming ? (
|
{confirming ? (
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span className="text-[0.75rem] text-danger font-medium">Delete?</span>
|
<span className="text-xs text-danger font-medium">Delete?</span>
|
||||||
<button
|
<button
|
||||||
onClick={e => { e.stopPropagation(); onDelete(); setConfirming(false) }}
|
onClick={e => { e.stopPropagation(); onDelete(); setConfirming(false) }}
|
||||||
className="text-[0.6875rem] font-medium text-danger hover:text-danger px-1.5 py-0.5 rounded bg-danger/15 hover:bg-danger/25 transition-colors"
|
className="text-[0.6875rem] font-medium text-danger hover:text-danger px-1.5 py-0.5 rounded bg-danger/15 hover:bg-danger/25 transition-colors"
|
||||||
@@ -222,12 +222,12 @@ function ChatItem({
|
|||||||
<div className="flex items-center gap-1.5 min-w-0">
|
<div className="flex items-center gap-1.5 min-w-0">
|
||||||
<div className="text-[0.8125rem] font-medium truncate">{chat.title}</div>
|
<div className="text-[0.8125rem] font-medium truncate">{chat.title}</div>
|
||||||
{chat.psa_ticket_id && (
|
{chat.psa_ticket_id && (
|
||||||
<span className="font-mono shrink-0 rounded-md bg-accent-dim px-1.5 py-0.5 text-[0.5625rem] text-accent-text">
|
<span className="font-mono shrink-0 rounded-md bg-accent-dim px-1.5 py-0.5 text-[0.625rem] text-accent-text">
|
||||||
#{chat.psa_ticket_id}
|
#{chat.psa_ticket_id}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{(chat.status === 'escalated' || chat.status === 'requesting_escalation') && (
|
{(chat.status === 'escalated' || chat.status === 'requesting_escalation') && (
|
||||||
<span className="font-sans shrink-0 rounded-md bg-warning-dim px-1.5 py-0.5 text-[0.5625rem] uppercase tracking-wider text-warning border border-warning/20">
|
<span className="shrink-0 rounded-md bg-warning-dim px-1.5 py-0.5 text-[0.625rem] uppercase tracking-wider text-warning border border-warning/20">
|
||||||
Escalated
|
Escalated
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -268,7 +268,7 @@ export function ConcludeSessionModal({
|
|||||||
)}
|
)}
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
'w-6 h-6 rounded-full flex items-center justify-center text-[0.6875rem] font-sans text-xs font-medium transition-colors',
|
'w-6 h-6 rounded-full flex items-center justify-center text-[0.6875rem] font-medium transition-colors',
|
||||||
step === s
|
step === s
|
||||||
? 'bg-primary text-white'
|
? 'bg-primary text-white'
|
||||||
: (i < ['select-outcome', 'add-notes', 'summary'].indexOf(step))
|
: (i < ['select-outcome', 'add-notes', 'summary'].indexOf(step))
|
||||||
@@ -280,7 +280,7 @@ export function ConcludeSessionModal({
|
|||||||
</div>
|
</div>
|
||||||
<span
|
<span
|
||||||
className={cn(
|
className={cn(
|
||||||
'text-xs font-sans text-xs',
|
'text-xs',
|
||||||
step === s ? 'text-foreground' : 'text-muted-foreground'
|
step === s ? 'text-foreground' : 'text-muted-foreground'
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
@@ -329,7 +329,7 @@ export function ConcludeSessionModal({
|
|||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{/* Selected outcome badge */}
|
{/* Selected outcome badge */}
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<div className={cn('px-3 py-1.5 rounded-lg flex items-center gap-2 text-xs font-sans text-xs', selectedOutcome.bg, selectedOutcome.border, 'border')}>
|
<div className={cn('px-3 py-1.5 rounded-lg flex items-center gap-2 text-xs', selectedOutcome.bg, selectedOutcome.border, 'border')}>
|
||||||
<selectedOutcome.icon size={14} className={selectedOutcome.color} />
|
<selectedOutcome.icon size={14} className={selectedOutcome.color} />
|
||||||
<span className={selectedOutcome.color}>{selectedOutcome.label}</span>
|
<span className={selectedOutcome.color}>{selectedOutcome.label}</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -342,7 +342,7 @@ export function ConcludeSessionModal({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="font-sans text-xs text-[0.625rem] uppercase tracking-widest text-muted-foreground block mb-2">
|
<label className="text-[0.625rem] uppercase tracking-widest text-muted-foreground block mb-2">
|
||||||
Additional Notes (optional)
|
Additional Notes (optional)
|
||||||
</label>
|
</label>
|
||||||
<textarea
|
<textarea
|
||||||
@@ -383,7 +383,7 @@ export function ConcludeSessionModal({
|
|||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{/* Outcome badge */}
|
{/* Outcome badge */}
|
||||||
{selectedOutcome && (
|
{selectedOutcome && (
|
||||||
<div className={cn('px-3 py-1.5 rounded-lg inline-flex items-center gap-2 text-xs font-sans text-xs', selectedOutcome.bg, selectedOutcome.border, 'border')}>
|
<div className={cn('px-3 py-1.5 rounded-lg inline-flex items-center gap-2 text-xs', selectedOutcome.bg, selectedOutcome.border, 'border')}>
|
||||||
<selectedOutcome.icon size={14} className={selectedOutcome.color} />
|
<selectedOutcome.icon size={14} className={selectedOutcome.color} />
|
||||||
<span className={selectedOutcome.color}>{selectedOutcome.label}</span>
|
<span className={selectedOutcome.color}>{selectedOutcome.label}</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -396,7 +396,7 @@ export function ConcludeSessionModal({
|
|||||||
style={{ borderColor: 'var(--color-border-default)' }}
|
style={{ borderColor: 'var(--color-border-default)' }}
|
||||||
>
|
>
|
||||||
<div className="flex items-center justify-between mb-3">
|
<div className="flex items-center justify-between mb-3">
|
||||||
<span className="font-sans text-xs text-[0.625rem] uppercase tracking-widest text-muted-foreground flex items-center gap-1.5">
|
<span className="text-[0.625rem] uppercase tracking-widest text-muted-foreground flex items-center gap-1.5">
|
||||||
<Sparkles size={10} className="text-primary" />
|
<Sparkles size={10} className="text-primary" />
|
||||||
Ticket Notes
|
Ticket Notes
|
||||||
</span>
|
</span>
|
||||||
@@ -488,7 +488,7 @@ export function ConcludeSessionModal({
|
|||||||
style={{ borderColor: 'var(--color-border-default)' }}
|
style={{ borderColor: 'var(--color-border-default)' }}
|
||||||
>
|
>
|
||||||
<div className="flex items-center justify-between mb-3">
|
<div className="flex items-center justify-between mb-3">
|
||||||
<span className="font-sans text-xs text-[0.625rem] uppercase tracking-widest text-muted-foreground flex items-center gap-1.5">
|
<span className="text-[0.625rem] uppercase tracking-widest text-muted-foreground flex items-center gap-1.5">
|
||||||
<Sparkles size={10} className="text-primary" />
|
<Sparkles size={10} className="text-primary" />
|
||||||
Status Update
|
Status Update
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -27,11 +27,11 @@ export function SuggestedFlowCard({ flow }: SuggestedFlowCardProps) {
|
|||||||
<span className="text-[0.8125rem] font-medium text-foreground truncate">
|
<span className="text-[0.8125rem] font-medium text-foreground truncate">
|
||||||
{flow.tree_name}
|
{flow.tree_name}
|
||||||
</span>
|
</span>
|
||||||
<span className="font-sans text-xs text-[0.625rem] uppercase tracking-wider text-muted-foreground">
|
<span className="text-[0.625rem] uppercase tracking-wider text-muted-foreground">
|
||||||
{flow.tree_type}
|
{flow.tree_type}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-[0.75rem] text-muted-foreground mt-0.5 line-clamp-2">
|
<p className="text-xs text-muted-foreground mt-0.5 line-clamp-2">
|
||||||
{flow.relevance_snippet}
|
{flow.relevance_snippet}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useState, useEffect, useRef, useCallback } from 'react'
|
import { useState, useEffect, useRef, useCallback } from 'react'
|
||||||
import {
|
import {
|
||||||
Copy, Check, SkipForward, Terminal, ChevronDown, ChevronUp,
|
Copy, Check, SkipForward, Terminal, ChevronDown, ChevronUp,
|
||||||
Send, Clipboard, Loader2, PanelRightClose, MessageCircleQuestion, Eye,
|
Send, Clipboard, Loader2, PanelRightClose, Pencil, HelpCircle, Eye,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
import { toast } from '@/lib/toast'
|
import { toast } from '@/lib/toast'
|
||||||
@@ -31,6 +31,62 @@ interface ActionResponse {
|
|||||||
|
|
||||||
type TaskResponse = QuestionResponse | ActionResponse
|
type TaskResponse = QuestionResponse | ActionResponse
|
||||||
|
|
||||||
|
interface DiagnosticHelp {
|
||||||
|
what: string
|
||||||
|
lookFor: string
|
||||||
|
usefulWhen: string
|
||||||
|
}
|
||||||
|
|
||||||
|
function getDiagnosticHelp(action: ActionResponse): DiagnosticHelp {
|
||||||
|
const command = (action.command || '').toLowerCase()
|
||||||
|
|
||||||
|
if (command.includes('test-netconnection') || command.includes('ping ')) {
|
||||||
|
return {
|
||||||
|
what: action.description || 'Checks whether the target is reachable over the network.',
|
||||||
|
lookFor: 'Successful replies, low packet loss, and whether the expected port shows as open.',
|
||||||
|
usefulWhen: 'Use it when you need to separate a service problem from a basic connectivity problem.',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (command.includes('nslookup') || command.includes('resolve-dnsname')) {
|
||||||
|
return {
|
||||||
|
what: action.description || 'Checks how DNS resolves the hostname or record.',
|
||||||
|
lookFor: 'Wrong IPs, NXDOMAIN responses, timeout errors, or different answers from different resolvers.',
|
||||||
|
usefulWhen: 'Use it when names fail but direct IP access may still work.',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (command.includes('ipconfig') || command.includes('get-netipconfiguration')) {
|
||||||
|
return {
|
||||||
|
what: action.description || 'Shows local IP, gateway, DNS, and adapter configuration.',
|
||||||
|
lookFor: 'APIPA addresses, missing gateways, wrong DNS servers, disconnected adapters, or stale leases.',
|
||||||
|
usefulWhen: 'Use it early when the symptom may be local network configuration.',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (command.includes('get-eventlog') || command.includes('get-winevent') || command.includes('eventlog')) {
|
||||||
|
return {
|
||||||
|
what: action.description || 'Reads Windows event logs for recent errors or warnings.',
|
||||||
|
lookFor: 'Events matching the failure time, repeated error IDs, service crashes, or permission failures.',
|
||||||
|
usefulWhen: 'Use it when the UI only shows a generic error and you need system-level evidence.',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (command.includes('get-service') || command.includes('restart-service')) {
|
||||||
|
return {
|
||||||
|
what: action.description || 'Checks service state on the affected machine.',
|
||||||
|
lookFor: 'Stopped services, restart loops, disabled startup types, or dependency failures.',
|
||||||
|
usefulWhen: 'Use it when a feature depends on a Windows service or background agent.',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
what: action.description || 'Runs the diagnostic check suggested by FlowPilot.',
|
||||||
|
lookFor: 'Errors, unexpected values, failed checks, or output that differs from a known-good machine.',
|
||||||
|
usefulWhen: 'Use it when you need evidence before choosing the next troubleshooting step.',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
interface TaskLaneProps {
|
interface TaskLaneProps {
|
||||||
questions: QuestionItem[]
|
questions: QuestionItem[]
|
||||||
actions: ActionItem[]
|
actions: ActionItem[]
|
||||||
@@ -98,6 +154,7 @@ export function TaskLane({ questions, actions, sessionId, onSubmit, onClose, loa
|
|||||||
const [showRunAll, setShowRunAll] = useState(false)
|
const [showRunAll, setShowRunAll] = useState(false)
|
||||||
const [showPreview, setShowPreview] = useState(false)
|
const [showPreview, setShowPreview] = useState(false)
|
||||||
const [copiedKey, setCopiedKey] = useState<string | null>(null)
|
const [copiedKey, setCopiedKey] = useState<string | null>(null)
|
||||||
|
const [expandedHelpKey, setExpandedHelpKey] = useState<string | null>(null)
|
||||||
|
|
||||||
// ── Resize state ──
|
// ── Resize state ──
|
||||||
const DEFAULT_WIDTH = 340
|
const DEFAULT_WIDTH = 340
|
||||||
@@ -166,22 +223,22 @@ export function TaskLane({ questions, actions, sessionId, onSubmit, onClose, loa
|
|||||||
questions: questionsRef.current.map(q => ({ text: q.text, context: q.context })),
|
questions: questionsRef.current.map(q => ({ text: q.text, context: q.context })),
|
||||||
actions: actionsRef.current.map(a => ({ label: a.label, command: a.command, description: a.description })),
|
actions: actionsRef.current.map(a => ({ label: a.label, command: a.command, description: a.description })),
|
||||||
responses: tasksRef.current as unknown as Array<Record<string, unknown>>,
|
responses: tasksRef.current as unknown as Array<Record<string, unknown>>,
|
||||||
}).catch(() => { /* silent — best-effort save */ })
|
}).catch(() => { /* silent - best-effort save */ })
|
||||||
}, 2000)
|
}, 2000)
|
||||||
return () => { if (saveTimerRef.current) clearTimeout(saveTimerRef.current) }
|
return () => { if (saveTimerRef.current) clearTimeout(saveTimerRef.current) }
|
||||||
}, [sessionId, tasks]) // eslint-disable-line react-hooks/exhaustive-deps
|
}, [sessionId, tasks])
|
||||||
|
|
||||||
// Reset when new tasks come in from AI response — but preserve saved state
|
// Reset when new tasks come in from AI response — but preserve saved state
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (sessionId) {
|
if (sessionId) {
|
||||||
const saved = loadTaskState(sessionId)
|
const saved = loadTaskState(sessionId)
|
||||||
if (saved && saved.length > 0) {
|
if (saved && saved.length > 0) {
|
||||||
// eslint-disable-next-line react-hooks/set-state-in-effect -- intentional: syncs derived state from prop changes
|
// eslint-disable-next-line react-hooks/set-state-in-effect -- intentional: syncs task UI from persisted session state
|
||||||
setTasks(saved)
|
setTasks(saved)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// eslint-disable-next-line react-hooks/set-state-in-effect -- intentional: syncs derived state from prop changes
|
|
||||||
setTasks([
|
setTasks([
|
||||||
...questions.map((q): QuestionResponse => ({
|
...questions.map((q): QuestionResponse => ({
|
||||||
type: 'question', text: q.text, context: q.context, state: 'pending', value: '',
|
type: 'question', text: q.text, context: q.context, state: 'pending', value: '',
|
||||||
@@ -190,12 +247,30 @@ export function TaskLane({ questions, actions, sessionId, onSubmit, onClose, loa
|
|||||||
type: 'action', label: a.label, command: a.command, description: a.description, state: 'pending', value: '',
|
type: 'action', label: a.label, command: a.command, description: a.description, state: 'pending', value: '',
|
||||||
})),
|
})),
|
||||||
])
|
])
|
||||||
}, [questions, actions]) // eslint-disable-line react-hooks/exhaustive-deps
|
}, [questions, actions, sessionId])
|
||||||
|
|
||||||
const updateTask = (idx: number, updates: Partial<TaskResponse>) => {
|
const updateTask = (idx: number, updates: Partial<TaskResponse>) => {
|
||||||
setTasks(prev => prev.map((t, i) => i === idx ? { ...t, ...updates } as TaskResponse : t))
|
setTasks(prev => prev.map((t, i) => i === idx ? { ...t, ...updates } as TaskResponse : t))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Mark `idx` done and advance focus to the next pending task. If none are
|
||||||
|
// left, focus the Send button so the engineer can fire the batch with one
|
||||||
|
// more keystroke. Powers both keyboard submit (Enter / Cmd+Enter) and the
|
||||||
|
// mouse path on the Answer / Done buttons.
|
||||||
|
const sendButtonRef = useRef<HTMLButtonElement>(null)
|
||||||
|
const submitAndAdvance = (idx: number, value: string) => {
|
||||||
|
if (!value.trim()) return
|
||||||
|
const nextIdx = tasks.findIndex((t, i) => i > idx && t.state === 'pending')
|
||||||
|
setTasks(prev => prev.map((t, i) => {
|
||||||
|
if (i === idx) return { ...t, state: 'done' } as TaskResponse
|
||||||
|
if (nextIdx !== -1 && i === nextIdx) return { ...t, state: 'active' } as TaskResponse
|
||||||
|
return t
|
||||||
|
}))
|
||||||
|
if (nextIdx === -1) {
|
||||||
|
setTimeout(() => sendButtonRef.current?.focus(), 50)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const questionTasks = tasks.filter(t => t.type === 'question')
|
const questionTasks = tasks.filter(t => t.type === 'question')
|
||||||
const actionTasks = tasks.filter(t => t.type === 'action') as ActionResponse[]
|
const actionTasks = tasks.filter(t => t.type === 'action') as ActionResponse[]
|
||||||
const allHandled = tasks.every(t => t.state === 'done' || t.state === 'skipped')
|
const allHandled = tasks.every(t => t.state === 'done' || t.state === 'skipped')
|
||||||
@@ -293,20 +368,21 @@ export function TaskLane({ questions, actions, sessionId, onSubmit, onClose, loa
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="px-4 py-3 border-b border-default flex items-center justify-between shrink-0" style={{ borderTop: '2px solid var(--color-accent)' }}>
|
<div className="px-4 py-3 border-b border-default flex items-center justify-between shrink-0">
|
||||||
<h3 className="font-heading text-sm font-bold text-heading flex items-center gap-2">
|
<h3 className="font-heading text-sm font-bold text-heading flex items-center gap-2">
|
||||||
Tasks
|
Tasks
|
||||||
<span className={cn(
|
{allHandled ? (
|
||||||
'text-[10px] font-semibold px-2 py-0.5 rounded-full',
|
<span className="flex items-center gap-1 text-[0.625rem] font-semibold uppercase tracking-wider text-success">
|
||||||
allHandled
|
<Check size={10} /> Ready
|
||||||
? 'bg-success-dim text-success'
|
</span>
|
||||||
: 'bg-accent-dim text-accent-text'
|
) : (
|
||||||
)}>
|
<span className="text-[0.625rem] font-medium tabular-nums text-muted-foreground">
|
||||||
{allHandled ? '✓ Ready' : `${doneCount}/${totalCount}`}
|
{doneCount}/{totalCount}
|
||||||
</span>
|
</span>
|
||||||
|
)}
|
||||||
{loading && (
|
{loading && (
|
||||||
<span
|
<span
|
||||||
className="flex items-center gap-1 text-[10px] font-medium text-muted-foreground"
|
className="flex items-center gap-1 text-[0.625rem] font-medium text-muted-foreground"
|
||||||
title="AI is thinking"
|
title="AI is thinking"
|
||||||
>
|
>
|
||||||
<Loader2 size={10} className="animate-spin" />
|
<Loader2 size={10} className="animate-spin" />
|
||||||
@@ -329,7 +405,7 @@ export function TaskLane({ questions, actions, sessionId, onSubmit, onClose, loa
|
|||||||
{questionTasks.length > 0 && (
|
{questionTasks.length > 0 && (
|
||||||
<section>
|
<section>
|
||||||
<div className="pb-2">
|
<div className="pb-2">
|
||||||
<div className="flex items-center gap-2 text-[10px] font-semibold uppercase tracking-[1.2px] text-muted-foreground pl-0.5">
|
<div className="flex items-center gap-2 text-[0.625rem] font-semibold uppercase tracking-[1.2px] text-muted-foreground pl-0.5">
|
||||||
<span className="w-1.5 h-1.5 rounded-full bg-accent" />
|
<span className="w-1.5 h-1.5 rounded-full bg-accent" />
|
||||||
Questions
|
Questions
|
||||||
{questionTasks.every(q => q.state === 'done' || q.state === 'skipped') && (
|
{questionTasks.every(q => q.state === 'done' || q.state === 'skipped') && (
|
||||||
@@ -344,12 +420,12 @@ export function TaskLane({ questions, actions, sessionId, onSubmit, onClose, loa
|
|||||||
|
|
||||||
if (q.state === 'done') {
|
if (q.state === 'done') {
|
||||||
return (
|
return (
|
||||||
<div key={idx} id={`task-lane-card-${idx}`} className="rounded-lg border-l-[3px] border-l-success border border-success/25 bg-success-dim/30 p-3 mb-2 cursor-pointer hover:border-success/40 transition-colors" onClick={() => updateTask(idx, { state: 'active' })}>
|
<div key={idx} id={`task-lane-card-${idx}`} className="rounded-lg border border-default/40 p-3 mb-2 cursor-pointer hover:border-default transition-colors" onClick={() => updateTask(idx, { state: 'active' })}>
|
||||||
<div className="flex items-center gap-1.5">
|
<div className="flex items-center gap-1.5">
|
||||||
<Check size={12} className="text-success shrink-0" />
|
<Check size={12} className="text-success shrink-0" />
|
||||||
<span className="text-[0.8125rem] text-foreground">{q.text}</span>
|
<span className="text-[0.8125rem] text-muted-foreground">{q.text}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-[0.75rem] text-muted-foreground mt-1 pl-5 italic truncate">"{q.value}"</div>
|
<div className="text-xs text-muted-foreground/80 mt-1 pl-5 italic truncate">"{q.value}"</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -359,7 +435,7 @@ export function TaskLane({ questions, actions, sessionId, onSubmit, onClose, loa
|
|||||||
<div key={idx} id={`task-lane-card-${idx}`} className="rounded-lg border border-default/50 bg-elevated/20 p-3 mb-2 opacity-60 cursor-pointer hover:opacity-80 hover:border-default transition-all" onClick={() => updateTask(idx, { state: 'pending' })} title="Click to restore">
|
<div key={idx} id={`task-lane-card-${idx}`} className="rounded-lg border border-default/50 bg-elevated/20 p-3 mb-2 opacity-60 cursor-pointer hover:opacity-80 hover:border-default transition-all" onClick={() => updateTask(idx, { state: 'pending' })} title="Click to restore">
|
||||||
<div className="flex justify-between">
|
<div className="flex justify-between">
|
||||||
<div className="text-[0.8125rem] text-muted-foreground line-through">{q.text}</div>
|
<div className="text-[0.8125rem] text-muted-foreground line-through">{q.text}</div>
|
||||||
<span className="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">Skipped</span>
|
<span className="text-[0.625rem] font-semibold uppercase tracking-wider text-muted-foreground">Skipped</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
@@ -377,33 +453,47 @@ export function TaskLane({ questions, actions, sessionId, onSubmit, onClose, loa
|
|||||||
autoFocus
|
autoFocus
|
||||||
value={q.value}
|
value={q.value}
|
||||||
onChange={e => updateTask(idx, { value: e.target.value })}
|
onChange={e => updateTask(idx, { value: e.target.value })}
|
||||||
|
onKeyDown={e => {
|
||||||
|
if (e.key === 'Enter' && !e.shiftKey) {
|
||||||
|
e.preventDefault()
|
||||||
|
submitAndAdvance(idx, q.value)
|
||||||
|
} else if (e.key === 'Escape') {
|
||||||
|
e.preventDefault()
|
||||||
|
updateTask(idx, { state: 'pending', value: '' })
|
||||||
|
}
|
||||||
|
}}
|
||||||
placeholder="Type your answer..."
|
placeholder="Type your answer..."
|
||||||
className="w-full rounded-md border border-default bg-input px-3 py-2 text-[0.8125rem] text-heading placeholder:text-muted-foreground resize-y min-h-[48px] max-h-[150px] focus:outline-none focus:border-accent focus:ring-1 focus:ring-accent/30"
|
className="w-full rounded-md border border-default bg-input px-3 py-2 text-[0.8125rem] text-heading placeholder:text-muted-foreground resize-y min-h-[48px] max-h-[150px] focus:outline-none focus:border-accent focus:ring-1 focus:ring-accent/30"
|
||||||
rows={2}
|
rows={2}
|
||||||
/>
|
/>
|
||||||
<div className="mt-1.5 flex items-center gap-2">
|
<div className="mt-1.5 flex items-center justify-between gap-2">
|
||||||
<button
|
<div className="flex items-center gap-2">
|
||||||
onClick={() => updateTask(idx, { state: 'done' })}
|
<button
|
||||||
disabled={!q.value.trim()}
|
onClick={() => submitAndAdvance(idx, q.value)}
|
||||||
className="flex items-center gap-1 rounded-md bg-accent px-2.5 py-1 text-[0.75rem] font-medium text-white disabled:opacity-40 hover:bg-accent-hover transition-colors"
|
disabled={!q.value.trim()}
|
||||||
>
|
className="flex items-center gap-1 rounded-md bg-accent px-2.5 py-1 text-xs font-medium text-white disabled:opacity-40 hover:bg-accent-hover transition-colors"
|
||||||
<Check size={11} /> Answer
|
>
|
||||||
</button>
|
<Check size={11} /> Answer
|
||||||
<button
|
</button>
|
||||||
onClick={() => updateTask(idx, { state: 'pending', value: '' })}
|
<button
|
||||||
className="text-[0.75rem] text-muted-foreground hover:text-heading"
|
onClick={() => updateTask(idx, { state: 'pending', value: '' })}
|
||||||
>
|
className="text-xs text-muted-foreground hover:text-heading"
|
||||||
Cancel
|
>
|
||||||
</button>
|
Cancel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<span className="text-[0.625rem] text-muted-foreground tabular-nums">
|
||||||
|
⏎ submit · ⇧⏎ newline
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="mt-2 flex items-center gap-2">
|
<div className="mt-2 flex items-center gap-2">
|
||||||
<button
|
<button
|
||||||
onClick={() => updateTask(idx, { state: 'active' })}
|
onClick={() => updateTask(idx, { state: 'active' })}
|
||||||
className="flex items-center gap-1 rounded-md border border-accent/40 bg-accent-dim/30 px-2.5 py-1.5 text-[0.75rem] font-medium text-accent-text hover:bg-accent-dim/50 transition-colors"
|
className="flex items-center gap-1 rounded-md border border-accent/40 bg-accent-dim/30 px-2.5 py-1.5 text-xs font-medium text-accent-text hover:bg-accent-dim/50 transition-colors"
|
||||||
>
|
>
|
||||||
<MessageCircleQuestion size={11} /> Answer
|
<Pencil size={11} /> Answer
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => updateTask(idx, { state: 'skipped' })}
|
onClick={() => updateTask(idx, { state: 'skipped' })}
|
||||||
@@ -424,7 +514,7 @@ export function TaskLane({ questions, actions, sessionId, onSubmit, onClose, loa
|
|||||||
{actionTasks.length > 0 && (
|
{actionTasks.length > 0 && (
|
||||||
<section>
|
<section>
|
||||||
<div className="pb-2">
|
<div className="pb-2">
|
||||||
<div className="flex items-center gap-2 text-[10px] font-semibold uppercase tracking-[1.2px] text-muted-foreground pl-0.5">
|
<div className="flex items-center gap-2 text-[0.625rem] font-semibold uppercase tracking-[1.2px] text-muted-foreground pl-0.5">
|
||||||
<span className="w-1.5 h-1.5 rounded-full bg-accent" />
|
<span className="w-1.5 h-1.5 rounded-full bg-accent" />
|
||||||
Diagnostic Checks
|
Diagnostic Checks
|
||||||
{actionTasks.every(a => a.state === 'done' || a.state === 'skipped') && (
|
{actionTasks.every(a => a.state === 'done' || a.state === 'skipped') && (
|
||||||
@@ -438,7 +528,7 @@ export function TaskLane({ questions, actions, sessionId, onSubmit, onClose, loa
|
|||||||
<div className="mb-2">
|
<div className="mb-2">
|
||||||
<button
|
<button
|
||||||
onClick={() => setShowRunAll(!showRunAll)}
|
onClick={() => setShowRunAll(!showRunAll)}
|
||||||
className="flex items-center gap-1.5 text-[0.75rem] font-medium text-accent-text hover:text-accent transition-colors pl-0.5"
|
className="flex items-center gap-1.5 text-xs font-medium text-accent-text hover:text-accent transition-colors pl-0.5"
|
||||||
>
|
>
|
||||||
<Terminal size={12} />
|
<Terminal size={12} />
|
||||||
Run All ({commandActions.length} commands)
|
Run All ({commandActions.length} commands)
|
||||||
@@ -447,16 +537,16 @@ export function TaskLane({ questions, actions, sessionId, onSubmit, onClose, loa
|
|||||||
{showRunAll && (
|
{showRunAll && (
|
||||||
<div className="mt-2 rounded-lg border border-default bg-code p-3">
|
<div className="mt-2 rounded-lg border border-default bg-code p-3">
|
||||||
<div className="flex items-center justify-between mb-2">
|
<div className="flex items-center justify-between mb-2">
|
||||||
<span className="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">Combined script</span>
|
<span className="text-[0.625rem] font-semibold uppercase tracking-wider text-muted-foreground">Combined script</span>
|
||||||
<button
|
<button
|
||||||
onClick={() => void handleCopy(combinedScript)}
|
onClick={() => void handleCopy(combinedScript)}
|
||||||
className="flex items-center gap-1 text-[0.75rem] text-muted-foreground hover:text-heading transition-colors"
|
className="flex items-center gap-1 text-xs text-muted-foreground hover:text-heading transition-colors"
|
||||||
>
|
>
|
||||||
{copiedKey === combinedScript ? <Check size={11} className="text-success" /> : <Copy size={11} />}
|
{copiedKey === combinedScript ? <Check size={11} className="text-success" /> : <Copy size={11} />}
|
||||||
{copiedKey === combinedScript ? 'Copied' : 'Copy'}
|
{copiedKey === combinedScript ? 'Copied' : 'Copy'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<pre className="text-[0.75rem] font-mono text-heading whitespace-pre-wrap overflow-x-auto">{combinedScript}</pre>
|
<pre className="text-xs font-mono text-heading whitespace-pre-wrap overflow-x-auto">{combinedScript}</pre>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -468,10 +558,10 @@ export function TaskLane({ questions, actions, sessionId, onSubmit, onClose, loa
|
|||||||
|
|
||||||
if (a.state === 'done') {
|
if (a.state === 'done') {
|
||||||
return (
|
return (
|
||||||
<div key={idx} id={`task-lane-card-${idx}`} className="rounded-lg border-l-[3px] border-l-success border border-success/25 bg-success-dim/30 p-3 mb-2 cursor-pointer hover:border-success/40 transition-colors" onClick={() => updateTask(idx, { state: 'active' })}>
|
<div key={idx} id={`task-lane-card-${idx}`} className="rounded-lg border border-default/40 p-3 mb-2 cursor-pointer hover:border-default transition-colors" onClick={() => updateTask(idx, { state: 'active' })}>
|
||||||
<div className="flex items-center gap-1.5">
|
<div className="flex items-center gap-1.5">
|
||||||
<Check size={12} className="text-success shrink-0" />
|
<Check size={12} className="text-success shrink-0" />
|
||||||
<span className="text-[0.8125rem] font-medium text-foreground flex-1">{a.label}</span>
|
<span className="text-[0.8125rem] text-muted-foreground flex-1">{a.label}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
@@ -482,7 +572,7 @@ export function TaskLane({ questions, actions, sessionId, onSubmit, onClose, loa
|
|||||||
<div key={idx} id={`task-lane-card-${idx}`} className="rounded-lg border border-default/50 bg-elevated/20 p-3 mb-2 opacity-60 cursor-pointer hover:opacity-80 hover:border-default transition-all" onClick={() => updateTask(idx, { state: 'pending' })} title="Click to restore">
|
<div key={idx} id={`task-lane-card-${idx}`} className="rounded-lg border border-default/50 bg-elevated/20 p-3 mb-2 opacity-60 cursor-pointer hover:opacity-80 hover:border-default transition-all" onClick={() => updateTask(idx, { state: 'pending' })} title="Click to restore">
|
||||||
<div className="flex justify-between">
|
<div className="flex justify-between">
|
||||||
<div className="text-[0.8125rem] text-muted-foreground line-through">{a.label}</div>
|
<div className="text-[0.8125rem] text-muted-foreground line-through">{a.label}</div>
|
||||||
<span className="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">Skipped</span>
|
<span className="text-[0.625rem] font-semibold uppercase tracking-wider text-muted-foreground">Skipped</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
@@ -490,10 +580,49 @@ export function TaskLane({ questions, actions, sessionId, onSubmit, onClose, loa
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={idx} id={`task-lane-card-${idx}`} className="rounded-lg border border-default bg-card p-3 mb-2 hover:border-hover transition-colors">
|
<div key={idx} id={`task-lane-card-${idx}`} className="rounded-lg border border-default bg-card p-3 mb-2 hover:border-hover transition-colors">
|
||||||
<div className="text-[0.8125rem] font-medium text-heading">{a.label}</div>
|
<div className="flex items-start justify-between gap-2">
|
||||||
{a.description && (
|
<div className="min-w-0 flex-1">
|
||||||
<div className="text-[0.6875rem] text-muted-foreground mt-0.5 leading-relaxed">{a.description}</div>
|
<div className="text-[0.8125rem] font-medium text-heading">{a.label}</div>
|
||||||
)}
|
{a.description && (
|
||||||
|
<div className="text-[0.6875rem] text-muted-foreground mt-0.5 leading-relaxed">{a.description}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setExpandedHelpKey(expandedHelpKey === `${idx}` ? null : `${idx}`)}
|
||||||
|
className={cn(
|
||||||
|
'shrink-0 rounded-md p-1.5 text-muted-foreground transition-colors hover:bg-elevated/50 hover:text-heading',
|
||||||
|
expandedHelpKey === `${idx}` && 'bg-accent-dim text-accent-text',
|
||||||
|
)}
|
||||||
|
title="Explain this check"
|
||||||
|
aria-label="Explain this diagnostic check"
|
||||||
|
aria-expanded={expandedHelpKey === `${idx}`}
|
||||||
|
>
|
||||||
|
<HelpCircle size={13} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{expandedHelpKey === `${idx}` && (() => {
|
||||||
|
const help = getDiagnosticHelp(a)
|
||||||
|
return (
|
||||||
|
<div className="mt-2 rounded-lg border border-info/20 bg-info-dim/20 p-2.5 text-[0.6875rem] leading-relaxed">
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<p>
|
||||||
|
<span className="font-semibold text-heading">What it checks: </span>
|
||||||
|
<span className="text-muted-foreground">{help.what}</span>
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<span className="font-semibold text-heading">What to look for: </span>
|
||||||
|
<span className="text-muted-foreground">{help.lookFor}</span>
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<span className="font-semibold text-heading">When to use it: </span>
|
||||||
|
<span className="text-muted-foreground">{help.usefulWhen}</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})()}
|
||||||
|
|
||||||
{a.command && (
|
{a.command && (
|
||||||
<div className="mt-2 flex items-center gap-2 rounded bg-code px-2.5 py-1.5">
|
<div className="mt-2 flex items-center gap-2 rounded bg-code px-2.5 py-1.5">
|
||||||
@@ -517,31 +646,45 @@ export function TaskLane({ questions, actions, sessionId, onSubmit, onClose, loa
|
|||||||
autoFocus
|
autoFocus
|
||||||
value={a.value}
|
value={a.value}
|
||||||
onChange={e => updateTask(idx, { value: e.target.value })}
|
onChange={e => updateTask(idx, { value: e.target.value })}
|
||||||
|
onKeyDown={e => {
|
||||||
|
if (e.key === 'Enter' && !e.shiftKey) {
|
||||||
|
e.preventDefault()
|
||||||
|
submitAndAdvance(idx, a.value)
|
||||||
|
} else if (e.key === 'Escape') {
|
||||||
|
e.preventDefault()
|
||||||
|
updateTask(idx, { state: 'pending', value: '' })
|
||||||
|
}
|
||||||
|
}}
|
||||||
placeholder="Paste command output here..."
|
placeholder="Paste command output here..."
|
||||||
className="w-full rounded-md border border-default bg-input px-3 py-2 text-[0.8125rem] text-heading placeholder:text-muted-foreground font-mono resize-y min-h-[60px] max-h-[200px] overflow-y-auto focus:outline-none focus:border-accent focus:ring-1 focus:ring-accent/30"
|
className="w-full rounded-md border border-default bg-input px-3 py-2 text-[0.8125rem] text-heading placeholder:text-muted-foreground font-mono resize-y min-h-[60px] max-h-[200px] overflow-y-auto focus:outline-none focus:border-accent focus:ring-1 focus:ring-accent/30"
|
||||||
rows={3}
|
rows={3}
|
||||||
/>
|
/>
|
||||||
<div className="mt-1.5 flex items-center gap-2">
|
<div className="mt-1.5 flex items-center justify-between gap-2">
|
||||||
<button
|
<div className="flex items-center gap-2">
|
||||||
onClick={() => updateTask(idx, { state: 'done' })}
|
<button
|
||||||
disabled={!a.value.trim()}
|
onClick={() => submitAndAdvance(idx, a.value)}
|
||||||
className="flex items-center gap-1 rounded-md bg-accent px-2.5 py-1 text-[0.75rem] font-medium text-white disabled:opacity-40 hover:bg-accent-hover transition-colors"
|
disabled={!a.value.trim()}
|
||||||
>
|
className="flex items-center gap-1 rounded-md bg-accent px-2.5 py-1 text-xs font-medium text-white disabled:opacity-40 hover:bg-accent-hover transition-colors"
|
||||||
<Check size={11} /> Done
|
>
|
||||||
</button>
|
<Check size={11} /> Done
|
||||||
<button
|
</button>
|
||||||
onClick={() => updateTask(idx, { state: 'pending', value: '' })}
|
<button
|
||||||
className="text-[0.75rem] text-muted-foreground hover:text-heading"
|
onClick={() => updateTask(idx, { state: 'pending', value: '' })}
|
||||||
>
|
className="text-xs text-muted-foreground hover:text-heading"
|
||||||
Cancel
|
>
|
||||||
</button>
|
Cancel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<span className="text-[0.625rem] text-muted-foreground tabular-nums">
|
||||||
|
⏎ submit · ⇧⏎ newline
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="mt-2 flex items-center gap-2">
|
<div className="mt-2 flex items-center gap-2">
|
||||||
<button
|
<button
|
||||||
onClick={() => updateTask(idx, { state: 'active' })}
|
onClick={() => updateTask(idx, { state: 'active' })}
|
||||||
className="flex items-center gap-1 rounded-md border border-accent/40 bg-accent-dim/30 px-2.5 py-1.5 text-[0.75rem] font-medium text-accent-text hover:bg-accent-dim/50 transition-colors"
|
className="flex items-center gap-1 rounded-md border border-accent/40 bg-accent-dim/30 px-2.5 py-1.5 text-xs font-medium text-accent-text hover:bg-accent-dim/50 transition-colors"
|
||||||
>
|
>
|
||||||
<Clipboard size={11} /> {a.command ? 'Paste Output' : 'Answer'}
|
<Clipboard size={11} /> {a.command ? 'Paste Output' : 'Answer'}
|
||||||
</button>
|
</button>
|
||||||
@@ -602,7 +745,7 @@ export function TaskLane({ questions, actions, sessionId, onSubmit, onClose, loa
|
|||||||
<div className="mb-2">
|
<div className="mb-2">
|
||||||
<button
|
<button
|
||||||
onClick={() => setShowPreview(!showPreview)}
|
onClick={() => setShowPreview(!showPreview)}
|
||||||
className="flex items-center gap-1.5 text-[0.75rem] font-medium text-muted-foreground hover:text-heading transition-colors mb-1"
|
className="flex items-center gap-1.5 text-xs font-medium text-muted-foreground hover:text-heading transition-colors mb-1"
|
||||||
>
|
>
|
||||||
<Eye size={12} />
|
<Eye size={12} />
|
||||||
Preview ({handledCount}/{totalCount} done)
|
Preview ({handledCount}/{totalCount} done)
|
||||||
@@ -616,6 +759,7 @@ export function TaskLane({ questions, actions, sessionId, onSubmit, onClose, loa
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<button
|
<button
|
||||||
|
ref={sendButtonRef}
|
||||||
onClick={handleSubmit}
|
onClick={handleSubmit}
|
||||||
disabled={!anyHandled || loading || submitting}
|
disabled={!anyHandled || loading || submitting}
|
||||||
className={cn(
|
className={cn(
|
||||||
|
|||||||
@@ -296,7 +296,7 @@ export function CommandPalette({ open, onClose }: CommandPaletteProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return result
|
return result
|
||||||
}, [query, searchFlows, searchSessions, searchAISessions, user])
|
}, [query, searchFlows, searchSessions, searchAISessions, user, onPilotSession])
|
||||||
|
|
||||||
// Flatten all items for keyboard navigation
|
// Flatten all items for keyboard navigation
|
||||||
const flatItems: PaletteItem[] = builtGroups.flatMap(g => g.items)
|
const flatItems: PaletteItem[] = builtGroups.flatMap(g => g.items)
|
||||||
@@ -401,6 +401,7 @@ export function CommandPalette({ open, onClose }: CommandPaletteProps) {
|
|||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
key={item.id}
|
key={item.id}
|
||||||
|
data-testid={item.id === 'flowpilot' ? 'command-palette-flowpilot' : undefined}
|
||||||
onClick={() => handleSelect(item)}
|
onClick={() => handleSelect(item)}
|
||||||
onMouseEnter={() => setSelectedIndex(itemGlobalIdx)}
|
onMouseEnter={() => setSelectedIndex(itemGlobalIdx)}
|
||||||
className={cn(
|
className={cn(
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useState, useEffect, useRef } from 'react'
|
import { useCallback, useState, useEffect, useRef } from 'react'
|
||||||
import { FolderPlus, Check, Plus } from 'lucide-react'
|
import { FolderPlus, Check, Plus } from 'lucide-react'
|
||||||
import { foldersApi } from '@/api/folders'
|
import { foldersApi } from '@/api/folders'
|
||||||
import type { FolderListItem } from '@/types'
|
import type { FolderListItem } from '@/types'
|
||||||
@@ -16,26 +16,7 @@ export function AddToFolderMenu({ treeId, onFolderCreated }: AddToFolderMenuProp
|
|||||||
const [isLoading, setIsLoading] = useState(false)
|
const [isLoading, setIsLoading] = useState(false)
|
||||||
const menuRef = useRef<HTMLDivElement>(null)
|
const menuRef = useRef<HTMLDivElement>(null)
|
||||||
|
|
||||||
useEffect(() => {
|
const loadFoldersAndAssignments = useCallback(async () => {
|
||||||
if (isOpen) {
|
|
||||||
loadFoldersAndAssignments()
|
|
||||||
}
|
|
||||||
}, [isOpen, treeId])
|
|
||||||
|
|
||||||
// Close on outside click
|
|
||||||
useEffect(() => {
|
|
||||||
const handleClickOutside = (e: MouseEvent) => {
|
|
||||||
if (menuRef.current && !menuRef.current.contains(e.target as Node)) {
|
|
||||||
setIsOpen(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (isOpen) {
|
|
||||||
document.addEventListener('mousedown', handleClickOutside)
|
|
||||||
}
|
|
||||||
return () => document.removeEventListener('mousedown', handleClickOutside)
|
|
||||||
}, [isOpen])
|
|
||||||
|
|
||||||
const loadFoldersAndAssignments = async () => {
|
|
||||||
setIsLoading(true)
|
setIsLoading(true)
|
||||||
try {
|
try {
|
||||||
const foldersData = await foldersApi.list()
|
const foldersData = await foldersApi.list()
|
||||||
@@ -59,7 +40,26 @@ export function AddToFolderMenu({ treeId, onFolderCreated }: AddToFolderMenuProp
|
|||||||
} finally {
|
} finally {
|
||||||
setIsLoading(false)
|
setIsLoading(false)
|
||||||
}
|
}
|
||||||
}
|
}, [treeId])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isOpen) {
|
||||||
|
loadFoldersAndAssignments()
|
||||||
|
}
|
||||||
|
}, [isOpen, loadFoldersAndAssignments])
|
||||||
|
|
||||||
|
// Close on outside click
|
||||||
|
useEffect(() => {
|
||||||
|
const handleClickOutside = (e: MouseEvent) => {
|
||||||
|
if (menuRef.current && !menuRef.current.contains(e.target as Node)) {
|
||||||
|
setIsOpen(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (isOpen) {
|
||||||
|
document.addEventListener('mousedown', handleClickOutside)
|
||||||
|
}
|
||||||
|
return () => document.removeEventListener('mousedown', handleClickOutside)
|
||||||
|
}, [isOpen])
|
||||||
|
|
||||||
const toggleFolder = async (folderId: string) => {
|
const toggleFolder = async (folderId: string) => {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -56,6 +56,14 @@ function getIndentedName(folders: FolderListItem[], folderId: string): string {
|
|||||||
return indent + (depth > 1 ? '└ ' : '') + (folder?.name || '')
|
return indent + (depth > 1 ? '└ ' : '') + (folder?.name || '')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Get path string for sorting
|
||||||
|
function getPath(allFolders: FolderListItem[], folderId: string): string {
|
||||||
|
const f = allFolders.find((x) => x.id === folderId)
|
||||||
|
if (!f) return ''
|
||||||
|
if (!f.parent_id) return f.name
|
||||||
|
return getPath(allFolders, f.parent_id) + '/' + f.name
|
||||||
|
}
|
||||||
|
|
||||||
export function FolderEditModal({
|
export function FolderEditModal({
|
||||||
folder,
|
folder,
|
||||||
parentId: initialParentId,
|
parentId: initialParentId,
|
||||||
@@ -110,14 +118,6 @@ export function FolderEditModal({
|
|||||||
})
|
})
|
||||||
}, [folder, folders])
|
}, [folder, folders])
|
||||||
|
|
||||||
// Get path string for sorting
|
|
||||||
function getPath(allFolders: FolderListItem[], folderId: string): string {
|
|
||||||
const f = allFolders.find((x) => x.id === folderId)
|
|
||||||
if (!f) return ''
|
|
||||||
if (!f.parent_id) return f.name
|
|
||||||
return getPath(allFolders, f.parent_id) + '/' + f.name
|
|
||||||
}
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (folder) {
|
if (folder) {
|
||||||
setName(folder.name)
|
setName(folder.name)
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useState, useEffect } from 'react'
|
import { useCallback, useState, useEffect } from 'react'
|
||||||
import { X, Copy, Check, Link2, Users, Lock, Globe } from 'lucide-react'
|
import { X, Copy, Check, Link2, Users, Lock, Globe } from 'lucide-react'
|
||||||
import type { TreeListItem, TreeShare, TreeVisibility } from '@/types'
|
import type { TreeListItem, TreeShare, TreeVisibility } from '@/types'
|
||||||
import { treesApi } from '@/api/trees'
|
import { treesApi } from '@/api/trees'
|
||||||
@@ -20,16 +20,7 @@ export function ShareTreeModal({ tree, isOpen, onClose }: ShareTreeModalProps) {
|
|||||||
const [allowForking, setAllowForking] = useState(true)
|
const [allowForking, setAllowForking] = useState(true)
|
||||||
const [visibility, setVisibility] = useState<TreeVisibility>('private')
|
const [visibility, setVisibility] = useState<TreeVisibility>('private')
|
||||||
|
|
||||||
useEffect(() => {
|
const loadShares = useCallback(async () => {
|
||||||
if (isOpen) {
|
|
||||||
loadShares()
|
|
||||||
// Reset state
|
|
||||||
setCopied(false)
|
|
||||||
setAllowForking(true)
|
|
||||||
}
|
|
||||||
}, [isOpen, tree.id])
|
|
||||||
|
|
||||||
const loadShares = async () => {
|
|
||||||
try {
|
try {
|
||||||
const sharesData = await treesApi.listShares(tree.id)
|
const sharesData = await treesApi.listShares(tree.id)
|
||||||
setShares(sharesData)
|
setShares(sharesData)
|
||||||
@@ -40,7 +31,16 @@ export function ShareTreeModal({ tree, isOpen, onClose }: ShareTreeModalProps) {
|
|||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to load shares:', err)
|
console.error('Failed to load shares:', err)
|
||||||
}
|
}
|
||||||
}
|
}, [tree.id])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isOpen) {
|
||||||
|
loadShares()
|
||||||
|
// Reset state
|
||||||
|
setCopied(false)
|
||||||
|
setAllowForking(true)
|
||||||
|
}
|
||||||
|
}, [isOpen, loadShares])
|
||||||
|
|
||||||
const handleGenerateLink = async () => {
|
const handleGenerateLink = async () => {
|
||||||
setIsGenerating(true)
|
setIsGenerating(true)
|
||||||
|
|||||||
@@ -57,7 +57,7 @@ function TabButton({
|
|||||||
aria-selected={active}
|
aria-selected={active}
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
className={cn(
|
className={cn(
|
||||||
'relative px-3 py-[7px] text-[12.5px] font-medium rounded-t-md transition-colors',
|
'relative px-3 py-[7px] text-xs font-medium rounded-t-md transition-colors',
|
||||||
'border-b-2 -mb-px',
|
'border-b-2 -mb-px',
|
||||||
active
|
active
|
||||||
? 'text-heading border-accent bg-bg-page'
|
? 'text-heading border-accent bg-bg-page'
|
||||||
|
|||||||
@@ -54,27 +54,24 @@ export function ProposalBanner(props: ProposalBannerProps) {
|
|||||||
|
|
||||||
function ProposedBanner({ fix, onApply, onDismiss, onToggleCollapsed }: ProposalBannerProps) {
|
function ProposedBanner({ fix, onApply, onDismiss, onToggleCollapsed }: ProposalBannerProps) {
|
||||||
return (
|
return (
|
||||||
<div className="relative border-t border-warning/30 bg-gradient-to-b from-warning-dim/40 to-warning-dim/20 px-5 py-3 animate-slide-up">
|
<div className="border-t border-warning/30 bg-card px-5 py-3 animate-slide-up">
|
||||||
<div className="absolute left-0 top-0 bottom-0 w-[3px] bg-warning" />
|
|
||||||
<div className="flex items-start gap-3">
|
<div className="flex items-start gap-3">
|
||||||
<div className="shrink-0 mt-0.5 w-7 h-7 rounded-md border border-warning/30 bg-warning-dim flex items-center justify-center text-warning">
|
<Sparkles size={16} className="text-warning shrink-0 mt-1" />
|
||||||
<Sparkles size={15} />
|
|
||||||
</div>
|
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<div className="flex items-center gap-2 font-heading text-[10px] font-semibold uppercase tracking-[1.2px] text-warning">
|
<div className="flex items-center gap-2 font-heading text-[0.625rem] font-semibold uppercase tracking-[1.2px] text-warning">
|
||||||
<span>Suggested Fix</span>
|
<span>Suggested Fix</span>
|
||||||
<span className="tabular-nums px-2 py-[2px] rounded-full bg-warning/20 text-warning text-[10.5px] font-bold">
|
<span className="tabular-nums px-2 py-[2px] rounded-full bg-warning/20 text-warning text-[0.625rem] font-bold">
|
||||||
{fix.confidence_pct}% confidence
|
{fix.confidence_pct}% confidence
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-0.5 text-[14px] font-semibold text-heading leading-snug">
|
<div className="mt-0.5 text-sm font-semibold text-heading leading-snug">
|
||||||
{fix.title}
|
{fix.title}
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-1 text-[12.5px] text-muted-foreground leading-relaxed">
|
<div className="mt-1 text-xs text-muted-foreground leading-relaxed">
|
||||||
{fix.description}
|
{fix.description}
|
||||||
</div>
|
</div>
|
||||||
{fix.script_template_id && (
|
{fix.script_template_id && (
|
||||||
<div className="mt-1.5 inline-flex items-center gap-1.5 text-[11.5px] text-success">
|
<div className="mt-1.5 inline-flex items-center gap-1.5 text-[0.6875rem] text-success">
|
||||||
<Check size={11} />
|
<Check size={11} />
|
||||||
Matches an existing Script Library template — one-click apply
|
Matches an existing Script Library template — one-click apply
|
||||||
</div>
|
</div>
|
||||||
@@ -92,13 +89,13 @@ function ProposedBanner({ fix, onApply, onDismiss, onToggleCollapsed }: Proposal
|
|||||||
)}
|
)}
|
||||||
<button
|
<button
|
||||||
onClick={onDismiss}
|
onClick={onDismiss}
|
||||||
className="px-2.5 py-1.5 rounded text-[12.5px] text-muted-foreground hover:bg-white/[0.08] hover:text-primary"
|
className="px-2.5 py-1.5 rounded text-xs text-muted-foreground hover:bg-white/[0.08] hover:text-primary"
|
||||||
>
|
>
|
||||||
Dismiss
|
Dismiss
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={onApply}
|
onClick={onApply}
|
||||||
className="px-3.5 py-[9px] rounded-lg bg-warning text-[#1a1200] font-semibold text-[12.5px] hover:brightness-110 inline-flex items-center gap-1.5"
|
className="px-3.5 py-[9px] rounded-lg bg-warning text-[#1a1200] font-semibold text-xs hover:brightness-110 inline-flex items-center gap-1.5"
|
||||||
>
|
>
|
||||||
Apply fix
|
Apply fix
|
||||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round"><polyline points="9 18 15 12 9 6" /></svg>
|
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round"><polyline points="9 18 15 12 9 6" /></svg>
|
||||||
@@ -116,27 +113,23 @@ function VerifyingBanner({ fix, onOutcome }: ProposalBannerProps) {
|
|||||||
: 'Applied'
|
: 'Applied'
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative border-t border-warning/30 bg-gradient-to-b from-warning-dim/40 to-warning-dim/20 px-5 py-3 animate-slide-up">
|
<div className="border-t border-warning/30 bg-card px-5 py-3 animate-slide-up">
|
||||||
<div className="absolute left-0 top-0 bottom-0 w-[3px] bg-warning" />
|
|
||||||
<div className="flex items-start gap-3">
|
<div className="flex items-start gap-3">
|
||||||
<div className="relative shrink-0 mt-0.5 w-7 h-7 rounded-md border border-warning/30 bg-warning-dim flex items-center justify-center text-warning">
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="text-warning shrink-0 mt-1">
|
||||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
<circle cx="12" cy="12" r="10" />
|
||||||
<circle cx="12" cy="12" r="10" />
|
<polyline points="12 6 12 12 16 14" />
|
||||||
<polyline points="12 6 12 12 16 14" />
|
</svg>
|
||||||
</svg>
|
|
||||||
<span className="absolute inset-[-3px] rounded-lg animate-pulse-amber pointer-events-none" />
|
|
||||||
</div>
|
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<div className="flex items-center gap-2 font-heading text-[10px] font-semibold uppercase tracking-[1.2px] text-warning">
|
<div className="flex items-center gap-2 font-heading text-[0.625rem] font-semibold uppercase tracking-[1.2px] text-warning">
|
||||||
<span>Verifying</span>
|
<span>Verifying</span>
|
||||||
<span className="px-2 py-[2px] rounded-full bg-warning/20 text-warning text-[10.5px] font-bold normal-case tracking-normal">
|
<span className="px-2 py-[2px] rounded-full bg-warning/20 text-warning text-[0.625rem] font-bold normal-case tracking-normal">
|
||||||
{appliedLabel}
|
{appliedLabel}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-0.5 text-[14px] font-semibold text-heading leading-snug">
|
<div className="mt-0.5 text-sm font-semibold text-heading leading-snug">
|
||||||
Did "{fix.title}" work?
|
Did "{fix.title}" work?
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-1 text-[12.5px] text-muted-foreground leading-relaxed">
|
<div className="mt-1 text-xs text-muted-foreground leading-relaxed">
|
||||||
Mark the outcome so the AI can either close the session with this as the resolution, or propose something else.
|
Mark the outcome so the AI can either close the session with this as the resolution, or propose something else.
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -159,7 +152,7 @@ function VerifyingBanner({ fix, onOutcome }: ProposalBannerProps) {
|
|||||||
const notes = window.prompt('What did you run / skip?')
|
const notes = window.prompt('What did you run / skip?')
|
||||||
if (notes && notes.trim()) onOutcome('applied_partial', notes.trim())
|
if (notes && notes.trim()) onOutcome('applied_partial', notes.trim())
|
||||||
}}
|
}}
|
||||||
className="w-full text-left px-3 py-2 text-[12.5px] hover:bg-elevated text-primary"
|
className="w-full text-left px-3 py-2 text-xs hover:bg-elevated text-primary"
|
||||||
>
|
>
|
||||||
Mark partial…
|
Mark partial…
|
||||||
</button>
|
</button>
|
||||||
@@ -169,7 +162,7 @@ function VerifyingBanner({ fix, onOutcome }: ProposalBannerProps) {
|
|||||||
const reason = window.prompt('What are you waiting on? (e.g. "client power-cycling router")')
|
const reason = window.prompt('What are you waiting on? (e.g. "client power-cycling router")')
|
||||||
if (reason && reason.trim()) onOutcome('applied_pending', reason.trim())
|
if (reason && reason.trim()) onOutcome('applied_pending', reason.trim())
|
||||||
}}
|
}}
|
||||||
className="w-full text-left px-3 py-2 text-[12.5px] hover:bg-elevated text-primary inline-flex items-center gap-2"
|
className="w-full text-left px-3 py-2 text-xs hover:bg-elevated text-primary inline-flex items-center gap-2"
|
||||||
>
|
>
|
||||||
<Clock3 size={12} className="text-info" />
|
<Clock3 size={12} className="text-info" />
|
||||||
Waiting to verify…
|
Waiting to verify…
|
||||||
@@ -181,14 +174,14 @@ function VerifyingBanner({ fix, onOutcome }: ProposalBannerProps) {
|
|||||||
const reason = window.prompt("Why didn't it work? (optional)")
|
const reason = window.prompt("Why didn't it work? (optional)")
|
||||||
onOutcome('applied_failed', reason?.trim() || undefined)
|
onOutcome('applied_failed', reason?.trim() || undefined)
|
||||||
}}
|
}}
|
||||||
className="px-3 py-[9px] rounded-lg border border-danger/30 text-danger text-[12.5px] font-medium hover:bg-danger-dim hover:border-danger inline-flex items-center gap-1.5"
|
className="px-3 py-[9px] rounded-lg border border-danger/30 text-danger text-xs font-medium hover:bg-danger-dim hover:border-danger inline-flex items-center gap-1.5"
|
||||||
>
|
>
|
||||||
<X size={12} strokeWidth={2.5} />
|
<X size={12} strokeWidth={2.5} />
|
||||||
Didn't work
|
Didn't work
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => onOutcome('applied_success')}
|
onClick={() => onOutcome('applied_success')}
|
||||||
className="px-3 py-[9px] rounded-lg bg-success text-[#0a1a12] font-semibold text-[12.5px] hover:brightness-110 inline-flex items-center gap-1.5"
|
className="px-3 py-[9px] rounded-lg bg-success text-[#0a1a12] font-semibold text-xs hover:brightness-110 inline-flex items-center gap-1.5"
|
||||||
>
|
>
|
||||||
<Check size={12} strokeWidth={2.5} />
|
<Check size={12} strokeWidth={2.5} />
|
||||||
It worked
|
It worked
|
||||||
@@ -209,25 +202,22 @@ function formatRelativeMinutes(iso: string): string {
|
|||||||
|
|
||||||
function PartialBanner({ fix, onOutcome, onApply }: ProposalBannerProps) {
|
function PartialBanner({ fix, onOutcome, onApply }: ProposalBannerProps) {
|
||||||
return (
|
return (
|
||||||
<div className="relative border-t border-info/30 bg-gradient-to-b from-info-dim/40 to-info-dim/20 px-5 py-3 animate-slide-up">
|
<div className="border-t border-info/30 bg-card px-5 py-3 animate-slide-up">
|
||||||
<div className="absolute left-0 top-0 bottom-0 w-[3px] bg-info" />
|
|
||||||
<div className="flex items-start gap-3">
|
<div className="flex items-start gap-3">
|
||||||
<div className="shrink-0 mt-0.5 w-7 h-7 rounded-md border border-info/30 bg-info-dim flex items-center justify-center text-info">
|
<Info size={16} className="text-info shrink-0 mt-1" />
|
||||||
<Info size={15} />
|
|
||||||
</div>
|
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<div className="flex items-center gap-2 font-heading text-[10px] font-semibold uppercase tracking-[1.2px] text-info">
|
<div className="flex items-center gap-2 font-heading text-[0.625rem] font-semibold uppercase tracking-[1.2px] text-info">
|
||||||
<span>Partially applied</span>
|
<span>Partially applied</span>
|
||||||
<span className="px-2 py-[2px] rounded-full bg-info/20 text-info text-[10.5px] font-bold normal-case tracking-normal">
|
<span className="px-2 py-[2px] rounded-full bg-info/20 text-info text-[0.625rem] font-bold normal-case tracking-normal">
|
||||||
Parked
|
Parked
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-0.5 text-[14px] font-semibold text-heading leading-snug">
|
<div className="mt-0.5 text-sm font-semibold text-heading leading-snug">
|
||||||
{fix.title}
|
{fix.title}
|
||||||
</div>
|
</div>
|
||||||
{fix.partial_notes && (
|
{fix.partial_notes && (
|
||||||
<div className="mt-1.5 flex items-center gap-2 px-2.5 py-1.5 rounded-md bg-info/[0.08] border border-info/30 text-[12px] italic text-primary">
|
<div className="mt-1.5 flex items-center gap-2 px-2.5 py-1.5 rounded-md bg-info/[0.08] border border-info/30 text-xs italic text-primary">
|
||||||
<span className="not-italic font-bold text-info text-[10.5px] uppercase tracking-[0.6px]">Note</span>
|
<span className="not-italic font-bold text-info text-[0.625rem] uppercase tracking-[0.6px]">Note</span>
|
||||||
<span>{fix.partial_notes}</span>
|
<span>{fix.partial_notes}</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -238,19 +228,19 @@ function PartialBanner({ fix, onOutcome, onApply }: ProposalBannerProps) {
|
|||||||
const reason = window.prompt("Why didn't it work? (optional)")
|
const reason = window.prompt("Why didn't it work? (optional)")
|
||||||
onOutcome('applied_failed', reason?.trim() || undefined)
|
onOutcome('applied_failed', reason?.trim() || undefined)
|
||||||
}}
|
}}
|
||||||
className="px-3 py-[9px] rounded-lg border border-danger/30 text-danger text-[12.5px] font-medium hover:bg-danger-dim hover:border-danger"
|
className="px-3 py-[9px] rounded-lg border border-danger/30 text-danger text-xs font-medium hover:bg-danger-dim hover:border-danger"
|
||||||
>
|
>
|
||||||
Didn't work
|
Didn't work
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={onApply}
|
onClick={onApply}
|
||||||
className="px-3 py-[9px] rounded-lg bg-card border border-white/10 text-primary text-[12.5px] font-medium hover:bg-elevated"
|
className="px-3 py-[9px] rounded-lg bg-card border border-white/10 text-primary text-xs font-medium hover:bg-elevated"
|
||||||
>
|
>
|
||||||
Finish it ›
|
Finish it ›
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => onOutcome('applied_success')}
|
onClick={() => onOutcome('applied_success')}
|
||||||
className="px-3 py-[9px] rounded-lg bg-success text-[#0a1a12] font-semibold text-[12.5px] hover:brightness-110"
|
className="px-3 py-[9px] rounded-lg bg-success text-[#0a1a12] font-semibold text-xs hover:brightness-110"
|
||||||
>
|
>
|
||||||
It worked
|
It worked
|
||||||
</button>
|
</button>
|
||||||
@@ -262,25 +252,22 @@ function PartialBanner({ fix, onOutcome, onApply }: ProposalBannerProps) {
|
|||||||
|
|
||||||
function PendingBanner({ fix, onOutcome, onDismiss }: ProposalBannerProps) {
|
function PendingBanner({ fix, onOutcome, onDismiss }: ProposalBannerProps) {
|
||||||
return (
|
return (
|
||||||
<div className="relative border-t border-info/30 bg-gradient-to-b from-info-dim/40 to-info-dim/20 px-5 py-3 animate-slide-up">
|
<div className="border-t border-info/30 bg-card px-5 py-3 animate-slide-up">
|
||||||
<div className="absolute left-0 top-0 bottom-0 w-[3px] bg-info" />
|
|
||||||
<div className="flex items-start gap-3">
|
<div className="flex items-start gap-3">
|
||||||
<div className="shrink-0 mt-0.5 w-7 h-7 rounded-md border border-info/30 bg-info-dim flex items-center justify-center text-info">
|
<Clock3 size={16} className="text-info shrink-0 mt-1" />
|
||||||
<Clock3 size={15} />
|
|
||||||
</div>
|
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<div className="flex items-center gap-2 font-heading text-[10px] font-semibold uppercase tracking-[1.2px] text-info">
|
<div className="flex items-center gap-2 font-heading text-[0.625rem] font-semibold uppercase tracking-[1.2px] text-info">
|
||||||
<span>Awaiting verification</span>
|
<span>Awaiting verification</span>
|
||||||
<span className="px-2 py-[2px] rounded-full bg-info/20 text-info text-[10.5px] font-bold normal-case tracking-normal">
|
<span className="px-2 py-[2px] rounded-full bg-info/20 text-info text-[0.625rem] font-bold normal-case tracking-normal">
|
||||||
Parked
|
Parked
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-0.5 text-[14px] font-semibold text-heading leading-snug">
|
<div className="mt-0.5 text-sm font-semibold text-heading leading-snug">
|
||||||
{fix.title}
|
{fix.title}
|
||||||
</div>
|
</div>
|
||||||
{fix.pending_reason && (
|
{fix.pending_reason && (
|
||||||
<div className="mt-1.5 flex items-center gap-2 px-2.5 py-1.5 rounded-md bg-info/[0.08] border border-info/30 text-[12px] italic text-primary">
|
<div className="mt-1.5 flex items-center gap-2 px-2.5 py-1.5 rounded-md bg-info/[0.08] border border-info/30 text-xs italic text-primary">
|
||||||
<span className="not-italic font-bold text-info text-[10.5px] uppercase tracking-[0.6px]">Waiting on</span>
|
<span className="not-italic font-bold text-info text-[0.625rem] uppercase tracking-[0.6px]">Waiting on</span>
|
||||||
<span>{fix.pending_reason}</span>
|
<span>{fix.pending_reason}</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -288,7 +275,7 @@ function PendingBanner({ fix, onOutcome, onDismiss }: ProposalBannerProps) {
|
|||||||
<div className="flex items-center gap-2 shrink-0 pt-0.5">
|
<div className="flex items-center gap-2 shrink-0 pt-0.5">
|
||||||
<button
|
<button
|
||||||
onClick={onDismiss}
|
onClick={onDismiss}
|
||||||
className="px-3 py-[9px] rounded-lg text-muted-foreground text-[12.5px] hover:bg-white/[0.08] hover:text-primary"
|
className="px-3 py-[9px] rounded-lg text-muted-foreground text-xs hover:bg-white/[0.08] hover:text-primary"
|
||||||
>
|
>
|
||||||
Dismiss
|
Dismiss
|
||||||
</button>
|
</button>
|
||||||
@@ -300,7 +287,7 @@ function PendingBanner({ fix, onOutcome, onDismiss }: ProposalBannerProps) {
|
|||||||
)
|
)
|
||||||
if (reason && reason.trim()) onOutcome('applied_pending', reason.trim())
|
if (reason && reason.trim()) onOutcome('applied_pending', reason.trim())
|
||||||
}}
|
}}
|
||||||
className="px-3 py-[9px] rounded-lg text-muted-foreground text-[12.5px] hover:bg-white/[0.08] hover:text-primary"
|
className="px-3 py-[9px] rounded-lg text-muted-foreground text-xs hover:bg-white/[0.08] hover:text-primary"
|
||||||
>
|
>
|
||||||
Update reason
|
Update reason
|
||||||
</button>
|
</button>
|
||||||
@@ -309,13 +296,13 @@ function PendingBanner({ fix, onOutcome, onDismiss }: ProposalBannerProps) {
|
|||||||
const reason = window.prompt("Why didn't it work? (optional)")
|
const reason = window.prompt("Why didn't it work? (optional)")
|
||||||
onOutcome('applied_failed', reason?.trim() || undefined)
|
onOutcome('applied_failed', reason?.trim() || undefined)
|
||||||
}}
|
}}
|
||||||
className="px-3 py-[9px] rounded-lg border border-danger/30 text-danger text-[12.5px] font-medium hover:bg-danger-dim hover:border-danger"
|
className="px-3 py-[9px] rounded-lg border border-danger/30 text-danger text-xs font-medium hover:bg-danger-dim hover:border-danger"
|
||||||
>
|
>
|
||||||
Didn't work
|
Didn't work
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => onOutcome('applied_success')}
|
onClick={() => onOutcome('applied_success')}
|
||||||
className="px-3 py-[9px] rounded-lg bg-success text-[#0a1a12] font-semibold text-[12.5px] hover:brightness-110 inline-flex items-center gap-1.5"
|
className="px-3 py-[9px] rounded-lg bg-success text-[#0a1a12] font-semibold text-xs hover:brightness-110 inline-flex items-center gap-1.5"
|
||||||
>
|
>
|
||||||
<Check size={12} strokeWidth={2.5} />
|
<Check size={12} strokeWidth={2.5} />
|
||||||
It worked
|
It worked
|
||||||
@@ -339,37 +326,34 @@ function AIConfirmingBanner({ fix, onAcceptAIProposal, onRejectAIProposal }: Pro
|
|||||||
: 'was partially applied'
|
: 'was partially applied'
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative border-t border-accent/30 bg-gradient-to-b from-accent-dim/40 to-accent-dim/20 px-5 py-3 animate-slide-up">
|
<div className="border-t border-accent/30 bg-card px-5 py-3 animate-slide-up">
|
||||||
<div className="absolute left-0 top-0 bottom-0 w-[3px] bg-accent" />
|
|
||||||
<div className="flex items-start gap-3">
|
<div className="flex items-start gap-3">
|
||||||
<div className="shrink-0 mt-0.5 w-7 h-7 rounded-md border border-accent/30 bg-accent-dim flex items-center justify-center text-accent">
|
<Sparkles size={16} className="text-accent shrink-0 mt-1" />
|
||||||
<Sparkles size={15} />
|
|
||||||
</div>
|
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<div className="flex items-center gap-2 font-heading text-[10px] font-semibold uppercase tracking-[1.2px] text-accent">
|
<div className="flex items-center gap-2 font-heading text-[0.625rem] font-semibold uppercase tracking-[1.2px] text-accent">
|
||||||
<span>AI detected outcome</span>
|
<span>AI detected outcome</span>
|
||||||
<span className="px-2 py-[2px] rounded-full bg-accent/20 text-accent text-[10.5px] font-bold normal-case tracking-normal">
|
<span className="px-2 py-[2px] rounded-full bg-accent/20 text-accent text-[0.625rem] font-bold normal-case tracking-normal">
|
||||||
{isSuccess ? 'Success' : isFailure ? 'Failure' : 'Partial'}
|
{isSuccess ? 'Success' : isFailure ? 'Failure' : 'Partial'}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-0.5 text-[14px] font-semibold text-heading leading-snug">
|
<div className="mt-0.5 text-sm font-semibold text-heading leading-snug">
|
||||||
AI thinks the fix {headlineVerb} — confirm?
|
AI thinks the fix {headlineVerb} — confirm?
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-1 text-[12.5px] text-muted-foreground leading-relaxed">
|
<div className="mt-1 text-xs text-muted-foreground leading-relaxed">
|
||||||
{proposal.reason || 'Based on the recent chat. One click either confirms or corrects.'}
|
{proposal.reason || 'Based on the recent chat. One click either confirms or corrects.'}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2 shrink-0 pt-0.5">
|
<div className="flex items-center gap-2 shrink-0 pt-0.5">
|
||||||
<button
|
<button
|
||||||
onClick={onRejectAIProposal}
|
onClick={onRejectAIProposal}
|
||||||
className="px-3 py-[9px] rounded-lg text-muted-foreground text-[12.5px] hover:bg-white/[0.08] hover:text-primary"
|
className="px-3 py-[9px] rounded-lg text-muted-foreground text-xs hover:bg-white/[0.08] hover:text-primary"
|
||||||
>
|
>
|
||||||
Not yet
|
Not yet
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={onAcceptAIProposal}
|
onClick={onAcceptAIProposal}
|
||||||
className={cn(
|
className={cn(
|
||||||
'px-3 py-[9px] rounded-lg font-semibold text-[12.5px] inline-flex items-center gap-1.5 hover:brightness-110',
|
'px-3 py-[9px] rounded-lg font-semibold text-xs inline-flex items-center gap-1.5 hover:brightness-110',
|
||||||
isSuccess
|
isSuccess
|
||||||
? 'bg-success text-[#0a1a12]'
|
? 'bg-success text-[#0a1a12]'
|
||||||
: 'bg-danger text-[#180808]',
|
: 'bg-danger text-[#180808]',
|
||||||
@@ -386,14 +370,13 @@ function AIConfirmingBanner({ fix, onAcceptAIProposal, onRejectAIProposal }: Pro
|
|||||||
|
|
||||||
function NudgeBanner({ fix, onOutcome, onSilenceNudge }: ProposalBannerProps) {
|
function NudgeBanner({ fix, onOutcome, onSilenceNudge }: ProposalBannerProps) {
|
||||||
return (
|
return (
|
||||||
<div className="relative border-t border-warning/30 bg-warning-dim/60 px-5 py-2 flex items-center gap-3">
|
<div className="border-t border-warning/30 bg-card px-5 py-2 flex items-center gap-3">
|
||||||
<div className="absolute left-0 top-0 bottom-0 w-[3px] bg-warning" />
|
|
||||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="text-warning shrink-0">
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="text-warning shrink-0">
|
||||||
<circle cx="12" cy="12" r="10" />
|
<circle cx="12" cy="12" r="10" />
|
||||||
<path d="M12 8v4" />
|
<path d="M12 8v4" />
|
||||||
<path d="M12 16h.01" />
|
<path d="M12 16h.01" />
|
||||||
</svg>
|
</svg>
|
||||||
<span className="flex-1 text-[12.5px] text-primary">
|
<span className="flex-1 text-xs text-primary">
|
||||||
Did <strong className="text-heading">"{fix.title}"</strong> work?
|
Did <strong className="text-heading">"{fix.title}"</strong> work?
|
||||||
</span>
|
</span>
|
||||||
<button
|
<button
|
||||||
@@ -407,7 +390,7 @@ function NudgeBanner({ fix, onOutcome, onSilenceNudge }: ProposalBannerProps) {
|
|||||||
onSilenceNudge()
|
onSilenceNudge()
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
className="px-2.5 py-1 rounded text-[12px] text-muted-foreground hover:bg-white/[0.08] hover:text-primary inline-flex items-center gap-1"
|
className="px-2.5 py-1 rounded text-xs text-muted-foreground hover:bg-white/[0.08] hover:text-primary inline-flex items-center gap-1"
|
||||||
>
|
>
|
||||||
<Clock3 size={11} />
|
<Clock3 size={11} />
|
||||||
Still checking
|
Still checking
|
||||||
@@ -417,13 +400,13 @@ function NudgeBanner({ fix, onOutcome, onSilenceNudge }: ProposalBannerProps) {
|
|||||||
const reason = window.prompt("Why didn't it work? (optional)")
|
const reason = window.prompt("Why didn't it work? (optional)")
|
||||||
onOutcome('applied_failed', reason?.trim() || undefined)
|
onOutcome('applied_failed', reason?.trim() || undefined)
|
||||||
}}
|
}}
|
||||||
className="px-2.5 py-1 rounded border border-danger/30 text-danger text-[12px] hover:bg-danger-dim"
|
className="px-2.5 py-1 rounded border border-danger/30 text-danger text-xs hover:bg-danger-dim"
|
||||||
>
|
>
|
||||||
No
|
No
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => onOutcome('applied_success')}
|
onClick={() => onOutcome('applied_success')}
|
||||||
className="px-2.5 py-1 rounded bg-success text-[#0a1a12] font-semibold text-[12px] hover:brightness-110"
|
className="px-2.5 py-1 rounded bg-success text-[#0a1a12] font-semibold text-xs hover:brightness-110"
|
||||||
>
|
>
|
||||||
Yes
|
Yes
|
||||||
</button>
|
</button>
|
||||||
@@ -435,15 +418,14 @@ function CollapsedBanner({ fix, onToggleCollapsed }: ProposalBannerProps) {
|
|||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
onClick={onToggleCollapsed}
|
onClick={onToggleCollapsed}
|
||||||
className="relative w-full border-t border-warning/30 bg-warning-dim/40 px-5 py-2 flex items-center gap-2.5 hover:bg-warning-dim/60 transition-colors text-left"
|
className="w-full border-t border-warning/30 bg-card px-5 py-2 flex items-center gap-2.5 hover:bg-[var(--color-bg-card-hover)] transition-colors text-left"
|
||||||
>
|
>
|
||||||
<div className="absolute left-0 top-0 bottom-0 w-[3px] bg-warning" />
|
|
||||||
<Sparkles size={12} className="text-warning shrink-0" />
|
<Sparkles size={12} className="text-warning shrink-0" />
|
||||||
<span className="flex-1 text-[12px] font-medium text-heading truncate">{fix.title}</span>
|
<span className="flex-1 text-xs font-medium text-heading truncate">{fix.title}</span>
|
||||||
<span className="px-1.5 py-[1px] rounded-full bg-warning/20 text-warning text-[10.5px] font-bold tabular-nums">
|
<span className="px-1.5 py-[1px] rounded-full bg-warning/20 text-warning text-[0.625rem] font-bold tabular-nums">
|
||||||
{fix.confidence_pct}%
|
{fix.confidence_pct}%
|
||||||
</span>
|
</span>
|
||||||
<span className="text-muted-foreground text-[11px]">▸ expand</span>
|
<span className="text-muted-foreground text-[0.6875rem]">▸ expand</span>
|
||||||
</button>
|
</button>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -80,10 +80,27 @@ function tokenize(body: string, highlightValues: Record<string, string> | undefi
|
|||||||
while (cursor < seg.text.length) {
|
while (cursor < seg.text.length) {
|
||||||
let matched: { key: string; value: string } | null = null
|
let matched: { key: string; value: string } | null = null
|
||||||
for (const [key, value] of valueEntries) {
|
for (const [key, value] of valueEntries) {
|
||||||
if (seg.text.startsWith(value, cursor)) {
|
if (!seg.text.startsWith(value, cursor)) continue
|
||||||
matched = { key, value }
|
// Word-boundary guard: a single-char value like "D" (drive letter)
|
||||||
break
|
// would otherwise light up every capital D in identifiers like
|
||||||
}
|
// `Get-ADUser`. We only require a boundary on a side of the value
|
||||||
|
// that itself starts/ends with a word char, so values that begin or
|
||||||
|
// end in punctuation (e.g. "D:\\Folder") still match cleanly.
|
||||||
|
const valueStartsWithWordChar = /^\w/.test(value)
|
||||||
|
const valueEndsWithWordChar = /\w$/.test(value)
|
||||||
|
const before = cursor > 0 ? seg.text[cursor - 1] : undefined
|
||||||
|
const after = cursor + value.length < seg.text.length
|
||||||
|
? seg.text[cursor + value.length]
|
||||||
|
: undefined
|
||||||
|
const startBounded = !valueStartsWithWordChar
|
||||||
|
|| before === undefined
|
||||||
|
|| !/\w/.test(before)
|
||||||
|
const endBounded = !valueEndsWithWordChar
|
||||||
|
|| after === undefined
|
||||||
|
|| !/\w/.test(after)
|
||||||
|
if (!startBounded || !endBounded) continue
|
||||||
|
matched = { key, value }
|
||||||
|
break
|
||||||
}
|
}
|
||||||
if (matched) {
|
if (matched) {
|
||||||
flushPending()
|
flushPending()
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ export function AddNoteButton({ onAdd }: AddNoteButtonProps) {
|
|||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
onClick={() => setOpen(true)}
|
onClick={() => setOpen(true)}
|
||||||
className="flex items-center gap-1.5 text-[0.75rem] text-muted-foreground hover:text-accent-text transition-colors pl-1 mt-1"
|
className="flex items-center gap-1.5 text-xs text-muted-foreground hover:text-accent-text transition-colors pl-1 mt-1"
|
||||||
>
|
>
|
||||||
<Plus size={12} />
|
<Plus size={12} />
|
||||||
Add a note
|
Add a note
|
||||||
@@ -62,20 +62,20 @@ export function AddNoteButton({ onAdd }: AddNoteButtonProps) {
|
|||||||
value={summary}
|
value={summary}
|
||||||
onChange={(e) => setSummary(e.target.value)}
|
onChange={(e) => setSummary(e.target.value)}
|
||||||
placeholder="Short label (optional)"
|
placeholder="Short label (optional)"
|
||||||
className="mt-1.5 w-full rounded-md border border-default bg-input px-2.5 py-1 text-[0.75rem] text-heading placeholder:text-muted-foreground focus:outline-none focus:border-accent focus:ring-1 focus:ring-accent/30"
|
className="mt-1.5 w-full rounded-md border border-default bg-input px-2.5 py-1 text-xs text-heading placeholder:text-muted-foreground focus:outline-none focus:border-accent focus:ring-1 focus:ring-accent/30"
|
||||||
/>
|
/>
|
||||||
<div className="mt-1.5 flex items-center gap-2">
|
<div className="mt-1.5 flex items-center gap-2">
|
||||||
<button
|
<button
|
||||||
onClick={handleSubmit}
|
onClick={handleSubmit}
|
||||||
disabled={busy || !text.trim()}
|
disabled={busy || !text.trim()}
|
||||||
className="flex items-center gap-1 rounded-md bg-accent px-2.5 py-1 text-[0.75rem] font-medium text-white disabled:opacity-40 hover:bg-accent-hover transition-colors"
|
className="flex items-center gap-1 rounded-md bg-accent px-2.5 py-1 text-xs font-medium text-white disabled:opacity-40 hover:bg-accent-hover transition-colors"
|
||||||
>
|
>
|
||||||
<Check size={11} /> Add
|
<Check size={11} /> Add
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={reset}
|
onClick={reset}
|
||||||
disabled={busy}
|
disabled={busy}
|
||||||
className="text-[0.75rem] text-muted-foreground hover:text-heading"
|
className="text-xs text-muted-foreground hover:text-heading"
|
||||||
>
|
>
|
||||||
Cancel
|
Cancel
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -11,8 +11,8 @@
|
|||||||
* and renders the section. Loading/refresh logic lives in the parent
|
* and renders the section. Loading/refresh logic lives in the parent
|
||||||
* (AssistantChatPage) so it can coordinate with the chat send cycle.
|
* (AssistantChatPage) so it can coordinate with the chat send cycle.
|
||||||
*/
|
*/
|
||||||
import { Loader2 } from 'lucide-react'
|
import { useState } from 'react'
|
||||||
import { cn } from '@/lib/utils'
|
import { ChevronDown, ChevronRight, Loader2 } from 'lucide-react'
|
||||||
import type { SessionFact } from '@/api/sessionFacts'
|
import type { SessionFact } from '@/api/sessionFacts'
|
||||||
import { WhatWeKnowItem } from './WhatWeKnowItem'
|
import { WhatWeKnowItem } from './WhatWeKnowItem'
|
||||||
import { AddNoteButton } from './AddNoteButton'
|
import { AddNoteButton } from './AddNoteButton'
|
||||||
@@ -23,32 +23,65 @@ interface WhatWeKnowProps {
|
|||||||
onUpdateFact: (factId: string, text: string, summary: string | null) => Promise<void> | void
|
onUpdateFact: (factId: string, text: string, summary: string | null) => Promise<void> | void
|
||||||
onDeleteFact: (factId: string) => Promise<void> | void
|
onDeleteFact: (factId: string) => Promise<void> | void
|
||||||
loading?: boolean
|
loading?: boolean
|
||||||
|
/** Used as the sessionStorage key for the engineer's collapse preference.
|
||||||
|
* When the parent re-keys this component on session change, the lazy
|
||||||
|
* initializer reads fresh state for the new session. */
|
||||||
|
sessionId?: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const COLLAPSE_STORAGE_KEY = 'rf-whatweknow-collapsed'
|
||||||
|
// First-render auto-collapse threshold. Past this, the section is hidden by
|
||||||
|
// default so Questions / Diagnostic Checks stay above the fold. The engineer's
|
||||||
|
// explicit toggle (stored per-session) always wins over this heuristic.
|
||||||
|
const AUTO_COLLAPSE_THRESHOLD = 5
|
||||||
|
|
||||||
export function WhatWeKnow({
|
export function WhatWeKnow({
|
||||||
facts,
|
facts,
|
||||||
onAddNote,
|
onAddNote,
|
||||||
onUpdateFact,
|
onUpdateFact,
|
||||||
onDeleteFact,
|
onDeleteFact,
|
||||||
loading,
|
loading,
|
||||||
|
sessionId,
|
||||||
}: WhatWeKnowProps) {
|
}: WhatWeKnowProps) {
|
||||||
const count = facts.length
|
const count = facts.length
|
||||||
|
|
||||||
|
const [collapsed, setCollapsed] = useState<boolean>(() => {
|
||||||
|
if (sessionId) {
|
||||||
|
try {
|
||||||
|
const stored = sessionStorage.getItem(`${COLLAPSE_STORAGE_KEY}:${sessionId}`)
|
||||||
|
if (stored !== null) return stored === '1'
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
}
|
||||||
|
return count >= AUTO_COLLAPSE_THRESHOLD
|
||||||
|
})
|
||||||
|
|
||||||
|
const toggle = () => {
|
||||||
|
setCollapsed(prev => {
|
||||||
|
const next = !prev
|
||||||
|
if (sessionId) {
|
||||||
|
try { sessionStorage.setItem(`${COLLAPSE_STORAGE_KEY}:${sessionId}`, next ? '1' : '0') } catch { /* ignore */ }
|
||||||
|
}
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section
|
<section className="rounded-lg p-3 -mx-1 mb-1">
|
||||||
className={cn(
|
<div className={collapsed ? '' : 'pb-2'}>
|
||||||
'rounded-lg p-3 -mx-1 mb-1',
|
<div className="flex items-center gap-2 pl-0.5">
|
||||||
// Subtle green-to-transparent gradient distinguishes this section
|
<button
|
||||||
// from the rest of the lane (mockup 01-session-primary.png).
|
type="button"
|
||||||
'bg-gradient-to-b from-success/[0.05] to-transparent',
|
onClick={toggle}
|
||||||
)}
|
aria-expanded={!collapsed}
|
||||||
>
|
aria-label={collapsed ? 'Expand What we know' : 'Collapse What we know'}
|
||||||
<div className="pb-2">
|
className="flex items-center gap-2 text-[0.625rem] font-semibold uppercase tracking-[1.2px] text-muted-foreground hover:text-heading transition-colors"
|
||||||
<div className="flex items-center gap-2 text-[10px] font-semibold uppercase tracking-[1.2px] text-muted-foreground pl-0.5">
|
>
|
||||||
<span className="w-1.5 h-1.5 rounded-full bg-success" />
|
{collapsed ? <ChevronRight size={10} /> : <ChevronDown size={10} />}
|
||||||
What we know
|
<span className="w-1.5 h-1.5 rounded-full bg-success" />
|
||||||
<span className="text-muted-foreground">·</span>
|
What we know
|
||||||
<span className="tabular-nums">{count}</span>
|
<span className="text-muted-foreground">·</span>
|
||||||
|
<span className="tabular-nums">{count}</span>
|
||||||
|
</button>
|
||||||
{loading && (
|
{loading && (
|
||||||
<span
|
<span
|
||||||
className="ml-auto flex items-center gap-1 text-[0.625rem] font-medium normal-case tracking-normal text-muted-foreground"
|
className="ml-auto flex items-center gap-1 text-[0.625rem] font-medium normal-case tracking-normal text-muted-foreground"
|
||||||
@@ -61,29 +94,33 @@ export function WhatWeKnow({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{count === 0 && loading && (
|
{!collapsed && (
|
||||||
<div className="space-y-2 px-1 py-2">
|
<>
|
||||||
<div className="h-3 w-3/4 rounded bg-elevated/60 animate-pulse" />
|
{count === 0 && loading && (
|
||||||
<div className="h-3 w-1/2 rounded bg-elevated/60 animate-pulse" />
|
<div className="space-y-2 px-1 py-2">
|
||||||
</div>
|
<div className="h-3 w-3/4 rounded bg-elevated/60 animate-pulse" />
|
||||||
|
<div className="h-3 w-1/2 rounded bg-elevated/60 animate-pulse" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{count === 0 && !loading && (
|
||||||
|
<div className="text-xs text-muted-foreground italic px-1 py-2">
|
||||||
|
Nothing confirmed yet — facts appear here as the engineer answers questions and runs checks.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{facts.map((fact) => (
|
||||||
|
<WhatWeKnowItem
|
||||||
|
key={fact.id}
|
||||||
|
fact={fact}
|
||||||
|
onSave={(text, summary) => onUpdateFact(fact.id, text, summary)}
|
||||||
|
onDelete={() => onDeleteFact(fact.id)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
|
||||||
|
<AddNoteButton onAdd={onAddNote} />
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{count === 0 && !loading && (
|
|
||||||
<div className="text-[0.75rem] text-muted-foreground italic px-1 py-2">
|
|
||||||
Nothing confirmed yet — facts appear here as the engineer answers questions and runs checks.
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{facts.map((fact) => (
|
|
||||||
<WhatWeKnowItem
|
|
||||||
key={fact.id}
|
|
||||||
fact={fact}
|
|
||||||
onSave={(text, summary) => onUpdateFact(fact.id, text, summary)}
|
|
||||||
onDelete={() => onDeleteFact(fact.id)}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
|
|
||||||
<AddNoteButton onAdd={onAddNote} />
|
|
||||||
</section>
|
</section>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -79,20 +79,20 @@ export function WhatWeKnowItem({ fact, onSave, onDelete }: WhatWeKnowItemProps)
|
|||||||
value={draftSummary}
|
value={draftSummary}
|
||||||
onChange={(e) => setDraftSummary(e.target.value)}
|
onChange={(e) => setDraftSummary(e.target.value)}
|
||||||
placeholder="Short label (e.g. 'rules out tenant/license')"
|
placeholder="Short label (e.g. 'rules out tenant/license')"
|
||||||
className="mt-1.5 w-full rounded-md border border-default bg-input px-2.5 py-1 text-[0.75rem] text-heading placeholder:text-muted-foreground focus:outline-none focus:border-accent focus:ring-1 focus:ring-accent/30"
|
className="mt-1.5 w-full rounded-md border border-default bg-input px-2.5 py-1 text-xs text-heading placeholder:text-muted-foreground focus:outline-none focus:border-accent focus:ring-1 focus:ring-accent/30"
|
||||||
/>
|
/>
|
||||||
<div className="mt-1.5 flex items-center gap-2">
|
<div className="mt-1.5 flex items-center gap-2">
|
||||||
<button
|
<button
|
||||||
onClick={handleSave}
|
onClick={handleSave}
|
||||||
disabled={busy || !draftText.trim()}
|
disabled={busy || !draftText.trim()}
|
||||||
className="flex items-center gap-1 rounded-md bg-accent px-2.5 py-1 text-[0.75rem] font-medium text-white disabled:opacity-40 hover:bg-accent-hover transition-colors"
|
className="flex items-center gap-1 rounded-md bg-accent px-2.5 py-1 text-xs font-medium text-white disabled:opacity-40 hover:bg-accent-hover transition-colors"
|
||||||
>
|
>
|
||||||
<Check size={11} /> Save
|
<Check size={11} /> Save
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={handleCancel}
|
onClick={handleCancel}
|
||||||
disabled={busy}
|
disabled={busy}
|
||||||
className="text-[0.75rem] text-muted-foreground hover:text-heading"
|
className="text-xs text-muted-foreground hover:text-heading"
|
||||||
>
|
>
|
||||||
Cancel
|
Cancel
|
||||||
</button>
|
</button>
|
||||||
@@ -104,7 +104,7 @@ export function WhatWeKnowItem({ fact, onSave, onDelete }: WhatWeKnowItemProps)
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
'group rounded-lg border-l-[3px] border-l-success border border-success/25 bg-success-dim/20 p-3 mb-2',
|
'group rounded-lg border border-default/40 p-3 mb-2',
|
||||||
busy && 'opacity-60',
|
busy && 'opacity-60',
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useState, useEffect, useRef } from 'react'
|
import { useCallback, useState, useEffect, useRef } from 'react'
|
||||||
import { Palette, Upload, Trash2, Loader2 } from 'lucide-react'
|
import { Palette, Upload, Trash2, Loader2 } from 'lucide-react'
|
||||||
import { getBranding, updateBranding, deleteLogo } from '@/api/branding'
|
import { getBranding, updateBranding, deleteLogo } from '@/api/branding'
|
||||||
import type { BrandingInfo } from '@/api/branding'
|
import type { BrandingInfo } from '@/api/branding'
|
||||||
@@ -23,11 +23,7 @@ export function BrandingSettings({ teamId }: BrandingSettingsProps) {
|
|||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||||
|
|
||||||
useEffect(() => {
|
const loadBranding = useCallback(async () => {
|
||||||
loadBranding()
|
|
||||||
}, [teamId])
|
|
||||||
|
|
||||||
const loadBranding = async () => {
|
|
||||||
setIsLoading(true)
|
setIsLoading(true)
|
||||||
try {
|
try {
|
||||||
const data = await getBranding(teamId)
|
const data = await getBranding(teamId)
|
||||||
@@ -44,7 +40,11 @@ export function BrandingSettings({ teamId }: BrandingSettingsProps) {
|
|||||||
} finally {
|
} finally {
|
||||||
setIsLoading(false)
|
setIsLoading(false)
|
||||||
}
|
}
|
||||||
}
|
}, [teamId])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadBranding()
|
||||||
|
}, [loadBranding])
|
||||||
|
|
||||||
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
const file = e.target.files?.[0]
|
const file = e.target.files?.[0]
|
||||||
|
|||||||
@@ -47,9 +47,9 @@ export function NodeEditorPanel({ nodeId, onClose, onSelectType }: NodeEditorPan
|
|||||||
if (node) {
|
if (node) {
|
||||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||||
setDraft(cloneWithoutChildren(node))
|
setDraft(cloneWithoutChildren(node))
|
||||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
|
||||||
setIsDirty(false)
|
setIsDirty(false)
|
||||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
|
||||||
setShowDeleteConfirm(false)
|
setShowDeleteConfirm(false)
|
||||||
}
|
}
|
||||||
}, [nodeId, node?.type]) // eslint-disable-line react-hooks/exhaustive-deps
|
}, [nodeId, node?.type]) // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|||||||
@@ -261,7 +261,7 @@ export function TreeCanvas() {
|
|||||||
})
|
})
|
||||||
setExpandedNodeId(null)
|
setExpandedNodeId(null)
|
||||||
},
|
},
|
||||||
[pendingLinks, treeStructure, updateNode]
|
[addNode, pendingLinks, treeStructure, updateNode]
|
||||||
)
|
)
|
||||||
|
|
||||||
// ── Cancel new node ──
|
// ── Cancel new node ──
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ export function useCachedQuota() {
|
|||||||
if (cachedResult && Date.now() - cachedResult.timestamp < CACHE_TTL_MS) {
|
if (cachedResult && Date.now() - cachedResult.timestamp < CACHE_TTL_MS) {
|
||||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||||
setAiEnabled(cachedResult.aiEnabled)
|
setAiEnabled(cachedResult.aiEnabled)
|
||||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
|
||||||
setIsLoading(false)
|
setIsLoading(false)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -153,7 +153,7 @@ export function AccountSettingsPage() {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadData()
|
loadData()
|
||||||
}, [])
|
}, []) // eslint-disable-line react-hooks/exhaustive-deps -- initial account load; mutations call loadData explicitly
|
||||||
|
|
||||||
const loadData = async () => {
|
const loadData = async () => {
|
||||||
setIsLoading(true)
|
setIsLoading(true)
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { handoffsApi } from '@/api/handoffs'
|
|||||||
import { timeAgo } from '@/lib/timeAgo'
|
import { timeAgo } from '@/lib/timeAgo'
|
||||||
import type { HandoffResponse } from '@/types/branching'
|
import type { HandoffResponse } from '@/types/branching'
|
||||||
import { HandoffContextScreen } from '@/components/flowpilot/HandoffContextScreen'
|
import { HandoffContextScreen } from '@/components/flowpilot/HandoffContextScreen'
|
||||||
import { Sparkles, Send, Loader2, MessageSquare, Paperclip, Terminal, X, RotateCcw, ImagePlus, ListChecks, FileText, CheckCircle2, ArrowUpRight, ArrowRight, MoreHorizontal, Pause, Plus, Copy, Check } from 'lucide-react'
|
import { Sparkles, Send, Loader2, MessageSquare, Paperclip, Terminal, X, RotateCcw, ImagePlus, ListChecks, FileText, CheckCircle2, ArrowUpRight, MoreHorizontal, Pause, Plus } from 'lucide-react'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
import { uploadsApi } from '@/api/uploads'
|
import { uploadsApi } from '@/api/uploads'
|
||||||
import type { PendingUpload } from '@/types/upload'
|
import type { PendingUpload } from '@/types/upload'
|
||||||
@@ -88,9 +88,6 @@ export default function AssistantChatPage() {
|
|||||||
// composer. Click prefills the input; first send hides the strip; explicit
|
// composer. Click prefills the input; first send hides the strip; explicit
|
||||||
// X also hides. Per-session lifetime — a refresh wipes the state, which is
|
// X also hides. Per-session lifetime — a refresh wipes the state, which is
|
||||||
// fine because the senior can re-open the Context overlay.
|
// fine because the senior can re-open the Context overlay.
|
||||||
const [chipsHidden, setChipsHidden] = useState(false)
|
|
||||||
const [selectedChipCardIdx, setSelectedChipCardIdx] = useState<number | null>(null)
|
|
||||||
const [copiedChipCmd, setCopiedChipCmd] = useState(false)
|
|
||||||
const [chats, setChats] = useState<ChatListItem[]>([])
|
const [chats, setChats] = useState<ChatListItem[]>([])
|
||||||
const [activeChatId, setActiveChatId] = useState<string | null>(() => {
|
const [activeChatId, setActiveChatId] = useState<string | null>(() => {
|
||||||
if (urlSessionId) return urlSessionId
|
if (urlSessionId) return urlSessionId
|
||||||
@@ -267,6 +264,15 @@ export default function AssistantChatPage() {
|
|||||||
// path: post-claim the chat surface had no messages and the senior
|
// path: post-claim the chat surface had no messages and the senior
|
||||||
// landed on a blank pane).
|
// landed on a blank pane).
|
||||||
const loadedChatIdsRef = useRef<Set<string>>(new Set())
|
const loadedChatIdsRef = useRef<Set<string>>(new Set())
|
||||||
|
const guardCurrentChat = useCallback((expectedChatId: string, source: string) => {
|
||||||
|
if (currentChatRef.current === expectedChatId) return true
|
||||||
|
console.warn('[AssistantChat] Discarded stale async result', {
|
||||||
|
source,
|
||||||
|
expectedChatId,
|
||||||
|
currentChatId: currentChatRef.current,
|
||||||
|
})
|
||||||
|
return false
|
||||||
|
}, [])
|
||||||
|
|
||||||
// Persist active chat ID to sessionStorage
|
// Persist active chat ID to sessionStorage
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -612,7 +618,7 @@ export default function AssistantChatPage() {
|
|||||||
}
|
}
|
||||||
window.addEventListener(PILOT_INLINE_SCRIPT_EVENT, handler as EventListener)
|
window.addEventListener(PILOT_INLINE_SCRIPT_EVENT, handler as EventListener)
|
||||||
return () => window.removeEventListener(PILOT_INLINE_SCRIPT_EVENT, handler as EventListener)
|
return () => window.removeEventListener(PILOT_INLINE_SCRIPT_EVENT, handler as EventListener)
|
||||||
}, [activeFix])
|
}, [activeFix, activeChatId])
|
||||||
|
|
||||||
const loadChats = async () => {
|
const loadChats = async () => {
|
||||||
try {
|
try {
|
||||||
@@ -684,7 +690,7 @@ export default function AssistantChatPage() {
|
|||||||
try {
|
try {
|
||||||
const list = await sessionFactsApi.list(chatId)
|
const list = await sessionFactsApi.list(chatId)
|
||||||
// Guard: discard stale fetch if the user switched chats mid-flight.
|
// Guard: discard stale fetch if the user switched chats mid-flight.
|
||||||
if (currentChatRef.current !== chatId) return
|
if (!guardCurrentChat(chatId, 'refreshFacts')) return
|
||||||
setFacts(list)
|
setFacts(list)
|
||||||
// Auto-open the task lane when the session has facts so the engineer
|
// Auto-open the task lane when the session has facts so the engineer
|
||||||
// can see them — without this, a session with only facts (no open
|
// can see them — without this, a session with only facts (no open
|
||||||
@@ -699,7 +705,7 @@ export default function AssistantChatPage() {
|
|||||||
// Best-effort — facts are accessory state. Surfacing a toast on every
|
// Best-effort — facts are accessory state. Surfacing a toast on every
|
||||||
// refetch failure would be noisy; the empty state explains the absence.
|
// refetch failure would be noisy; the empty state explains the absence.
|
||||||
}
|
}
|
||||||
}, [])
|
}, [guardCurrentChat])
|
||||||
|
|
||||||
// Phase 3 — active suggested fix + resolution-note preview.
|
// Phase 3 — active suggested fix + resolution-note preview.
|
||||||
// Declared BEFORE refreshSessionDerived / handleAddNote so the useCallback
|
// Declared BEFORE refreshSessionDerived / handleAddNote so the useCallback
|
||||||
@@ -707,7 +713,7 @@ export default function AssistantChatPage() {
|
|||||||
const refreshActiveFix = useCallback(async (chatId: string) => {
|
const refreshActiveFix = useCallback(async (chatId: string) => {
|
||||||
try {
|
try {
|
||||||
const fix = await sessionSuggestedFixesApi.getActive(chatId)
|
const fix = await sessionSuggestedFixesApi.getActive(chatId)
|
||||||
if (currentChatRef.current !== chatId) return
|
if (!guardCurrentChat(chatId, 'refreshActiveFix')) return
|
||||||
setActiveFix((prev) => {
|
setActiveFix((prev) => {
|
||||||
// If the active fix changed (AI emitted a new SUGGEST_FIX that
|
// If the active fix changed (AI emitted a new SUGGEST_FIX that
|
||||||
// superseded the prior), close the script panel so the engineer
|
// superseded the prior), close the script panel so the engineer
|
||||||
@@ -719,7 +725,7 @@ export default function AssistantChatPage() {
|
|||||||
// No-fix-yet (404) is normalized to null inside the client. Genuine
|
// No-fix-yet (404) is normalized to null inside the client. Genuine
|
||||||
// failures stay silent — accessory state, not load-bearing.
|
// failures stay silent — accessory state, not load-bearing.
|
||||||
}
|
}
|
||||||
}, [])
|
}, [guardCurrentChat])
|
||||||
|
|
||||||
// Kind-aware preview fetch: Resolve hits /resolution-note/preview,
|
// Kind-aware preview fetch: Resolve hits /resolution-note/preview,
|
||||||
// Escalate hits /escalation-package/preview. They're cached separately
|
// Escalate hits /escalation-package/preview. They're cached separately
|
||||||
@@ -733,7 +739,7 @@ export default function AssistantChatPage() {
|
|||||||
const p = effectiveKind === 'resolve'
|
const p = effectiveKind === 'resolve'
|
||||||
? await sessionSuggestedFixesApi.getResolutionNotePreview(chatId)
|
? await sessionSuggestedFixesApi.getResolutionNotePreview(chatId)
|
||||||
: await sessionSuggestedFixesApi.getEscalationPackagePreview(chatId)
|
: await sessionSuggestedFixesApi.getEscalationPackagePreview(chatId)
|
||||||
if (currentChatRef.current !== chatId) return
|
if (!guardCurrentChat(chatId, 'refreshPreview')) return
|
||||||
setPreviewData(p)
|
setPreviewData(p)
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
const status = (err as { response?: { status?: number } })?.response?.status
|
const status = (err as { response?: { status?: number } })?.response?.status
|
||||||
@@ -745,7 +751,7 @@ export default function AssistantChatPage() {
|
|||||||
} finally {
|
} finally {
|
||||||
setPreviewLoading(false)
|
setPreviewLoading(false)
|
||||||
}
|
}
|
||||||
}, [previewKind])
|
}, [guardCurrentChat, previewKind])
|
||||||
|
|
||||||
// Trigger preview refresh with a 500ms debounce. The backend cache short-
|
// Trigger preview refresh with a 500ms debounce. The backend cache short-
|
||||||
// circuits same-state calls, but the network round-trip is still avoidable
|
// circuits same-state calls, but the network round-trip is still avoidable
|
||||||
@@ -880,7 +886,7 @@ export default function AssistantChatPage() {
|
|||||||
}
|
}
|
||||||
// No draft, no template — route to the Script Builder tab.
|
// No draft, no template — route to the Script Builder tab.
|
||||||
setChatTab('script_builder')
|
setChatTab('script_builder')
|
||||||
}, [activeFix])
|
}, [activeFix, activeChatId])
|
||||||
|
|
||||||
// Phase 9 Task 13: TemplateMatchPanel "I ran this" — stamps applied_at so the
|
// Phase 9 Task 13: TemplateMatchPanel "I ran this" — stamps applied_at so the
|
||||||
// ProposalBanner transitions from Proposed to Verifying. Shared useCallback so
|
// ProposalBanner transitions from Proposed to Verifying. Shared useCallback so
|
||||||
@@ -903,6 +909,10 @@ export default function AssistantChatPage() {
|
|||||||
try {
|
try {
|
||||||
const updated = await sessionSuggestedFixesApi.patchOutcome(activeChatId, activeFix.id, outcome, notes)
|
const updated = await sessionSuggestedFixesApi.patchOutcome(activeChatId, activeFix.id, outcome, notes)
|
||||||
setActiveFix(updated)
|
setActiveFix(updated)
|
||||||
|
// Banner and script panel are linked surfaces: once an outcome is
|
||||||
|
// recorded, the script-execution affordance has done its job, so close
|
||||||
|
// it alongside the banner state transition.
|
||||||
|
setScriptPanelOpen(false)
|
||||||
// Reset apply tracking state since we now have a terminal outcome.
|
// Reset apply tracking state since we now have a terminal outcome.
|
||||||
setPostApplyMsgCount(0)
|
setPostApplyMsgCount(0)
|
||||||
setNudgeSilenced(false)
|
setNudgeSilenced(false)
|
||||||
@@ -1108,13 +1118,13 @@ export default function AssistantChatPage() {
|
|||||||
// Guard: if the user switched to a different chat while this API call was
|
// Guard: if the user switched to a different chat while this API call was
|
||||||
// in flight (e.g. clicked "New Chat"), discard stale results so we don't
|
// in flight (e.g. clicked "New Chat"), discard stale results so we don't
|
||||||
// clobber the new session's task lane state.
|
// clobber the new session's task lane state.
|
||||||
if (currentChatRef.current !== chatId) return
|
if (!guardCurrentChat(chatId, 'selectChat')) return
|
||||||
setActiveSessionStatus(detail.status)
|
setActiveSessionStatus(detail.status)
|
||||||
setActivePsaTicketId(detail.psa_ticket_id)
|
setActivePsaTicketId(detail.psa_ticket_id)
|
||||||
if (detail.psa_ticket_id) {
|
if (detail.psa_ticket_id) {
|
||||||
integrationsApi.getTicket(detail.psa_ticket_id)
|
integrationsApi.getTicket(detail.psa_ticket_id)
|
||||||
.then(ticket => {
|
.then(ticket => {
|
||||||
if (currentChatRef.current !== chatId) return
|
if (!guardCurrentChat(chatId, 'selectChat.ticket')) return
|
||||||
setLinkedTicket(ticket)
|
setLinkedTicket(ticket)
|
||||||
})
|
})
|
||||||
.catch(() => {})
|
.catch(() => {})
|
||||||
@@ -1149,7 +1159,7 @@ export default function AssistantChatPage() {
|
|||||||
} catch {
|
} catch {
|
||||||
setMessages([])
|
setMessages([])
|
||||||
}
|
}
|
||||||
}, [refreshSessionDerived])
|
}, [guardCurrentChat, refreshSessionDerived, resetSessionDerivedState])
|
||||||
|
|
||||||
const handleAIAnalysis = useCallback(async () => {
|
const handleAIAnalysis = useCallback(async () => {
|
||||||
if (!urlSessionId || !magicHandoff) return
|
if (!urlSessionId || !magicHandoff) return
|
||||||
@@ -1162,7 +1172,7 @@ export default function AssistantChatPage() {
|
|||||||
setMagicState('dismissed')
|
setMagicState('dismissed')
|
||||||
void loadChats()
|
void loadChats()
|
||||||
await selectChat(urlSessionId)
|
await selectChat(urlSessionId)
|
||||||
if (currentChatRef.current !== sentForChatId) return
|
if (!guardCurrentChat(sentForChatId, 'handleAIAnalysis.afterSelect')) return
|
||||||
|
|
||||||
const assessment = magicHandoff.ai_assessment_data
|
const assessment = magicHandoff.ai_assessment_data
|
||||||
const snapshot = magicHandoff.snapshot as Record<string, unknown>
|
const snapshot = magicHandoff.snapshot as Record<string, unknown>
|
||||||
@@ -1192,7 +1202,7 @@ export default function AssistantChatPage() {
|
|||||||
setMessages(prev => [...prev, { role: 'user', content: briefing }])
|
setMessages(prev => [...prev, { role: 'user', content: briefing }])
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
const response = await aiSessionsApi.sendChatMessage(urlSessionId, { message: briefing })
|
const response = await aiSessionsApi.sendChatMessage(urlSessionId, { message: briefing })
|
||||||
if (currentChatRef.current !== sentForChatId) return
|
if (!guardCurrentChat(sentForChatId, 'handleAIAnalysis.chatResponse')) return
|
||||||
setMessages(prev => [
|
setMessages(prev => [
|
||||||
...prev,
|
...prev,
|
||||||
{
|
{
|
||||||
@@ -1233,7 +1243,7 @@ export default function AssistantChatPage() {
|
|||||||
setActiveOptionKey(null)
|
setActiveOptionKey(null)
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
}
|
}
|
||||||
}, [urlSessionId, magicHandoff, setSearchParams, selectChat])
|
}, [guardCurrentChat, urlSessionId, magicHandoff, setSearchParams, selectChat])
|
||||||
|
|
||||||
const handleNewChat = async () => {
|
const handleNewChat = async () => {
|
||||||
// Invalidate currentChatRef BEFORE the await so any in-flight handleSend/handleTaskSubmit
|
// Invalidate currentChatRef BEFORE the await so any in-flight handleSend/handleTaskSubmit
|
||||||
@@ -1295,7 +1305,6 @@ export default function AssistantChatPage() {
|
|||||||
.map((u) => u.preview)
|
.map((u) => u.preview)
|
||||||
setInput('')
|
setInput('')
|
||||||
setPendingUploads([])
|
setPendingUploads([])
|
||||||
setChipsHidden(true)
|
|
||||||
setMessages(prev => [...prev, { role: 'user', content: userMessage, imageUrls: imageUrls.length > 0 ? imageUrls : undefined }])
|
setMessages(prev => [...prev, { role: 'user', content: userMessage, imageUrls: imageUrls.length > 0 ? imageUrls : undefined }])
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
|
|
||||||
@@ -1306,7 +1315,7 @@ export default function AssistantChatPage() {
|
|||||||
upload_ids: completedUploadIds.length > 0 ? completedUploadIds : undefined,
|
upload_ids: completedUploadIds.length > 0 ? completedUploadIds : undefined,
|
||||||
})
|
})
|
||||||
// Guard: discard if user switched to a different chat while this was in flight
|
// Guard: discard if user switched to a different chat while this was in flight
|
||||||
if (currentChatRef.current !== sentForChatId) return
|
if (!guardCurrentChat(sentForChatId, 'handleSend')) return
|
||||||
analytics.aiFeatureUsed({ feature: 'assistant_chat' })
|
analytics.aiFeatureUsed({ feature: 'assistant_chat' })
|
||||||
setMessages(prev => [
|
setMessages(prev => [
|
||||||
...prev,
|
...prev,
|
||||||
@@ -1396,7 +1405,7 @@ export default function AssistantChatPage() {
|
|||||||
try {
|
try {
|
||||||
const response = await aiSessionsApi.sendChatMessage(activeChatId, { message: userMessage })
|
const response = await aiSessionsApi.sendChatMessage(activeChatId, { message: userMessage })
|
||||||
// Guard: discard if user switched to a different chat while this was in flight
|
// Guard: discard if user switched to a different chat while this was in flight
|
||||||
if (currentChatRef.current !== sentForChatId) return
|
if (!guardCurrentChat(sentForChatId, 'handleTaskSubmit')) return
|
||||||
setMessages(prev => [
|
setMessages(prev => [
|
||||||
...prev,
|
...prev,
|
||||||
{ role: 'assistant', content: response.content, suggestedFlows: response.suggested_flows, fork: response.fork, actions: response.actions, questions: response.questions },
|
{ role: 'assistant', content: response.content, suggestedFlows: response.suggested_flows, fork: response.fork, actions: response.actions, questions: response.questions },
|
||||||
@@ -1491,7 +1500,7 @@ export default function AssistantChatPage() {
|
|||||||
|
|
||||||
const response = await aiSessionsApi.sendChatMessage(session.session_id, { message: resumePrompt })
|
const response = await aiSessionsApi.sendChatMessage(session.session_id, { message: resumePrompt })
|
||||||
// Guard: discard if user switched to a different chat while this was in flight
|
// Guard: discard if user switched to a different chat while this was in flight
|
||||||
if (currentChatRef.current !== session.session_id) return
|
if (!guardCurrentChat(session.session_id, 'handleResumeNew')) return
|
||||||
setMessages(prev => [
|
setMessages(prev => [
|
||||||
...prev,
|
...prev,
|
||||||
{ role: 'assistant', content: response.content, suggestedFlows: response.suggested_flows, fork: response.fork, actions: response.actions, questions: response.questions },
|
{ role: 'assistant', content: response.content, suggestedFlows: response.suggested_flows, fork: response.fork, actions: response.actions, questions: response.questions },
|
||||||
@@ -1760,27 +1769,10 @@ export default function AssistantChatPage() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Desktop actions — shown when session is active and has messages */}
|
{/* Desktop actions — Resolve + Escalate stay first-class; everything
|
||||||
|
else (Context / New Ticket / Update Ticket / Pause) folds behind
|
||||||
|
a single kebab to keep the header to two visible primary actions. */}
|
||||||
<div className="hidden sm:flex items-center gap-1.5">
|
<div className="hidden sm:flex items-center gap-1.5">
|
||||||
{magicHandoff && (
|
|
||||||
<button
|
|
||||||
onClick={openHandoffContextOverlay}
|
|
||||||
disabled={overlayLoading}
|
|
||||||
title="Show the handoff context the original engineer sent"
|
|
||||||
className="flex items-center gap-1.5 rounded-lg border border-default px-3 py-1.5 text-xs font-medium text-muted-foreground hover:text-foreground hover:border-hover disabled:opacity-40 transition-colors"
|
|
||||||
>
|
|
||||||
<Sparkles size={13} />
|
|
||||||
Context
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
{activePsaTicketId && (
|
|
||||||
<button
|
|
||||||
onClick={() => { setSpinOffHint(undefined); setShowNewTicket(true) }}
|
|
||||||
className="flex items-center gap-1 px-2 py-1 text-xs text-muted-foreground border border-default rounded-[5px] hover:border-hover hover:text-primary transition-colors"
|
|
||||||
>
|
|
||||||
<Plus className="w-3 h-3" /> New Ticket
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
{isActive && (
|
{isActive && (
|
||||||
<>
|
<>
|
||||||
<button
|
<button
|
||||||
@@ -1793,55 +1785,76 @@ export default function AssistantChatPage() {
|
|||||||
Resolve
|
Resolve
|
||||||
</button>
|
</button>
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<button
|
<button
|
||||||
onClick={handleEscalateClick}
|
onClick={handleEscalateClick}
|
||||||
disabled={!canAct}
|
disabled={!canAct}
|
||||||
data-conclude-outcome="escalated"
|
data-conclude-outcome="escalated"
|
||||||
className="flex items-center gap-1.5 rounded-lg bg-warning-dim border border-warning/20 px-3 py-1.5 text-xs font-medium text-warning hover:bg-warning/20 disabled:opacity-40 disabled:pointer-events-none transition-colors"
|
className="flex items-center gap-1.5 rounded-lg bg-warning-dim border border-warning/20 px-3 py-1.5 text-xs font-medium text-warning hover:bg-warning/20 disabled:opacity-40 disabled:pointer-events-none transition-colors"
|
||||||
>
|
>
|
||||||
<ArrowUpRight size={13} />
|
<ArrowUpRight size={13} />
|
||||||
Escalate
|
Escalate
|
||||||
</button>
|
</button>
|
||||||
{escalateIntercept && (
|
{escalateIntercept && (
|
||||||
<EscalateInterceptDialog
|
<EscalateInterceptDialog
|
||||||
fixTitle={escalateIntercept.fixTitle}
|
fixTitle={escalateIntercept.fixTitle}
|
||||||
onChoose={handleInterceptChoice}
|
onChoose={handleInterceptChoice}
|
||||||
onClose={() => setEscalateIntercept(null)}
|
onClose={() => setEscalateIntercept(null)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{messages.length >= 2 && (
|
{(magicHandoff || activePsaTicketId || messages.length >= 2) && (
|
||||||
<button
|
|
||||||
onClick={() => setShowStatusUpdate(true)}
|
|
||||||
disabled={loading}
|
|
||||||
className="flex items-center gap-1.5 rounded-lg bg-accent-dim border border-accent/20 px-3 py-1.5 text-xs font-medium text-accent hover:bg-accent/20 disabled:opacity-40 disabled:pointer-events-none transition-colors"
|
|
||||||
>
|
|
||||||
<FileText size={13} />
|
|
||||||
{updateLabel}
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
{/* Overflow: Pause / — */}
|
|
||||||
{isActive && messages.length >= 2 && (
|
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<button
|
<button
|
||||||
onClick={() => setShowOverflow(!showOverflow)}
|
onClick={() => setShowOverflow(!showOverflow)}
|
||||||
className="flex items-center justify-center rounded-lg px-2 py-1.5 text-muted-foreground hover:text-foreground hover:bg-[rgba(255,255,255,0.06)] transition-colors"
|
className="flex items-center justify-center rounded-lg px-2 py-1.5 text-muted-foreground hover:text-foreground hover:bg-[rgba(255,255,255,0.06)] transition-colors"
|
||||||
|
aria-label="More session actions"
|
||||||
>
|
>
|
||||||
<MoreHorizontal size={16} />
|
<MoreHorizontal size={16} />
|
||||||
</button>
|
</button>
|
||||||
{showOverflow && (
|
{showOverflow && (
|
||||||
<>
|
<>
|
||||||
<div className="fixed inset-0 z-40" onClick={() => setShowOverflow(false)} />
|
<div className="fixed inset-0 z-40" onClick={() => setShowOverflow(false)} />
|
||||||
<div className="absolute right-0 top-full mt-1 z-50 w-36 rounded-lg border border-border bg-card py-1 shadow-lg">
|
<div className="absolute right-0 top-full mt-1 z-50 w-44 rounded-lg border border-border bg-card py-1 shadow-lg">
|
||||||
<button
|
{magicHandoff && (
|
||||||
onClick={() => { setShowOverflow(false); aiSessionsApi.pauseSession(activeChatId).then(() => setActiveSessionStatus('paused')).catch(() => toast.error('Failed to pause')) }}
|
<button
|
||||||
className="flex w-full items-center gap-2 px-3 py-2 text-xs text-muted-foreground hover:text-foreground hover:bg-[rgba(255,255,255,0.06)] transition-colors"
|
onClick={() => { setShowOverflow(false); openHandoffContextOverlay() }}
|
||||||
>
|
disabled={overlayLoading}
|
||||||
<Pause size={13} />
|
className="flex w-full items-center gap-2 px-3 py-2 text-xs text-muted-foreground hover:text-foreground hover:bg-[rgba(255,255,255,0.06)] transition-colors disabled:opacity-40"
|
||||||
Pause
|
>
|
||||||
</button>
|
<Sparkles size={13} />
|
||||||
|
Context
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{activePsaTicketId && (
|
||||||
|
<button
|
||||||
|
onClick={() => { setShowOverflow(false); setSpinOffHint(undefined); setShowNewTicket(true) }}
|
||||||
|
className="flex w-full items-center gap-2 px-3 py-2 text-xs text-muted-foreground hover:text-foreground hover:bg-[rgba(255,255,255,0.06)] transition-colors"
|
||||||
|
>
|
||||||
|
<Plus size={13} />
|
||||||
|
New Ticket
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{messages.length >= 2 && (
|
||||||
|
<button
|
||||||
|
onClick={() => { setShowOverflow(false); setShowStatusUpdate(true) }}
|
||||||
|
disabled={loading}
|
||||||
|
className="flex w-full items-center gap-2 px-3 py-2 text-xs text-muted-foreground hover:text-foreground hover:bg-[rgba(255,255,255,0.06)] transition-colors disabled:opacity-40"
|
||||||
|
>
|
||||||
|
<FileText size={13} />
|
||||||
|
{updateLabel}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{isActive && messages.length >= 2 && (
|
||||||
|
<button
|
||||||
|
onClick={() => { setShowOverflow(false); aiSessionsApi.pauseSession(activeChatId).then(() => setActiveSessionStatus('paused')).catch(() => toast.error('Failed to pause')) }}
|
||||||
|
className="flex w-full items-center gap-2 px-3 py-2 text-xs text-muted-foreground hover:text-foreground hover:bg-[rgba(255,255,255,0.06)] transition-colors"
|
||||||
|
>
|
||||||
|
<Pause size={13} />
|
||||||
|
Pause
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
@@ -1849,12 +1862,14 @@ export default function AssistantChatPage() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Mobile: single overflow menu */}
|
{/* Mobile: single overflow menu — same items as desktop kebab plus
|
||||||
{messages.length >= 2 && (
|
Resolve/Escalate (which live in the visible row on desktop). */}
|
||||||
|
{(magicHandoff || activePsaTicketId || messages.length >= 2) && (
|
||||||
<div className="sm:hidden relative">
|
<div className="sm:hidden relative">
|
||||||
<button
|
<button
|
||||||
onClick={() => setShowOverflow(!showOverflow)}
|
onClick={() => setShowOverflow(!showOverflow)}
|
||||||
className="flex items-center justify-center rounded-lg px-2 py-1.5 text-muted-foreground hover:text-foreground hover:bg-[rgba(255,255,255,0.06)] transition-colors"
|
className="flex items-center justify-center rounded-lg px-2 py-1.5 text-muted-foreground hover:text-foreground hover:bg-[rgba(255,255,255,0.06)] transition-colors"
|
||||||
|
aria-label="Session actions"
|
||||||
>
|
>
|
||||||
<MoreHorizontal size={18} />
|
<MoreHorizontal size={18} />
|
||||||
</button>
|
</button>
|
||||||
@@ -1862,7 +1877,7 @@ export default function AssistantChatPage() {
|
|||||||
<>
|
<>
|
||||||
<div className="fixed inset-0 z-40" onClick={() => setShowOverflow(false)} />
|
<div className="fixed inset-0 z-40" onClick={() => setShowOverflow(false)} />
|
||||||
<div className="absolute right-0 top-full mt-1 z-50 w-44 rounded-lg border border-border bg-card py-1 shadow-lg">
|
<div className="absolute right-0 top-full mt-1 z-50 w-44 rounded-lg border border-border bg-card py-1 shadow-lg">
|
||||||
{isActive && (
|
{isActive && messages.length >= 2 && (
|
||||||
<>
|
<>
|
||||||
<button
|
<button
|
||||||
onClick={() => { setShowOverflow(false); handleResolveClick() }}
|
onClick={() => { setShowOverflow(false); handleResolveClick() }}
|
||||||
@@ -1893,15 +1908,36 @@ export default function AssistantChatPage() {
|
|||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
<button
|
{magicHandoff && (
|
||||||
onClick={() => { setShowOverflow(false); setShowStatusUpdate(true) }}
|
<button
|
||||||
disabled={loading}
|
onClick={() => { setShowOverflow(false); openHandoffContextOverlay() }}
|
||||||
className="flex w-full items-center gap-2 px-3 py-2.5 text-xs text-accent hover:bg-accent-dim transition-colors disabled:opacity-40"
|
disabled={overlayLoading}
|
||||||
>
|
className="flex w-full items-center gap-2 px-3 py-2.5 text-xs text-muted-foreground hover:text-foreground hover:bg-[rgba(255,255,255,0.06)] transition-colors disabled:opacity-40"
|
||||||
<FileText size={14} />
|
>
|
||||||
{updateLabel}
|
<Sparkles size={14} />
|
||||||
</button>
|
Context
|
||||||
{isActive && (
|
</button>
|
||||||
|
)}
|
||||||
|
{activePsaTicketId && (
|
||||||
|
<button
|
||||||
|
onClick={() => { setShowOverflow(false); setSpinOffHint(undefined); setShowNewTicket(true) }}
|
||||||
|
className="flex w-full items-center gap-2 px-3 py-2.5 text-xs text-muted-foreground hover:text-foreground hover:bg-[rgba(255,255,255,0.06)] transition-colors"
|
||||||
|
>
|
||||||
|
<Plus size={14} />
|
||||||
|
New Ticket
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{messages.length >= 2 && (
|
||||||
|
<button
|
||||||
|
onClick={() => { setShowOverflow(false); setShowStatusUpdate(true) }}
|
||||||
|
disabled={loading}
|
||||||
|
className="flex w-full items-center gap-2 px-3 py-2.5 text-xs text-accent hover:bg-accent-dim transition-colors disabled:opacity-40"
|
||||||
|
>
|
||||||
|
<FileText size={14} />
|
||||||
|
{updateLabel}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{isActive && messages.length >= 2 && (
|
||||||
<button
|
<button
|
||||||
onClick={() => { setShowOverflow(false); aiSessionsApi.pauseSession(activeChatId).then(() => setActiveSessionStatus('paused')).catch(() => toast.error('Failed to pause')) }}
|
onClick={() => { setShowOverflow(false); aiSessionsApi.pauseSession(activeChatId).then(() => setActiveSessionStatus('paused')).catch(() => toast.error('Failed to pause')) }}
|
||||||
className="flex w-full items-center gap-2 px-3 py-2.5 text-xs text-muted-foreground hover:text-foreground hover:bg-[rgba(255,255,255,0.06)] transition-colors"
|
className="flex w-full items-center gap-2 px-3 py-2.5 text-xs text-muted-foreground hover:text-foreground hover:bg-[rgba(255,255,255,0.06)] transition-colors"
|
||||||
@@ -1932,8 +1968,11 @@ export default function AssistantChatPage() {
|
|||||||
Hidden (not unmounted) when Script Builder tab is active so
|
Hidden (not unmounted) when Script Builder tab is active so
|
||||||
scroll position and input state are preserved. */}
|
scroll position and input state are preserved. */}
|
||||||
<div className={cn('flex-1 min-h-0 flex flex-col', chatTab !== 'chat' && 'hidden')}>
|
<div className={cn('flex-1 min-h-0 flex flex-col', chatTab !== 'chat' && 'hidden')}>
|
||||||
{/* Messages */}
|
{/* Messages — scroll container is full width (so the scrollbar lives at
|
||||||
<div className="flex-1 overflow-y-auto px-4 sm:px-6 py-4 space-y-4">
|
the chat-column edge) but content is centered to max-w-3xl to match
|
||||||
|
the composer below, giving the column a single anchor. */}
|
||||||
|
<div className="flex-1 overflow-y-auto px-4 sm:px-6 py-4">
|
||||||
|
<div className="max-w-3xl mx-auto space-y-4">
|
||||||
{messages.length === 0 && !loading && (
|
{messages.length === 0 && !loading && (
|
||||||
<div className="flex flex-col items-center justify-center h-full text-center">
|
<div className="flex flex-col items-center justify-center h-full text-center">
|
||||||
<div className="w-16 h-16 rounded-full bg-accent-dim flex items-center justify-center mb-4">
|
<div className="w-16 h-16 rounded-full bg-accent-dim flex items-center justify-center mb-4">
|
||||||
@@ -1948,26 +1987,41 @@ export default function AssistantChatPage() {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{messages.map((msg, i) => (
|
{(() => {
|
||||||
<ChatMessage
|
// Action emphasis is shown on the *current* turn only — i.e. the
|
||||||
key={i}
|
// latest assistant message when active items are pending and the
|
||||||
role={msg.role}
|
// magic-moment hero has dismissed. The TaskLane remains the
|
||||||
content={msg.content}
|
// canonical list; this is just an inline cue.
|
||||||
suggestedFlows={msg.suggestedFlows}
|
let lastAssistantIdx = -1
|
||||||
imageUrls={msg.imageUrls}
|
for (let i = messages.length - 1; i >= 0; i--) {
|
||||||
/>
|
if (messages[i].role === 'assistant') { lastAssistantIdx = i; break }
|
||||||
))}
|
}
|
||||||
|
const showActionEmphasis = magicState === 'dismissed'
|
||||||
|
&& (activeQuestions.length + activeActions.length) > 0
|
||||||
|
const turnActionCount = activeQuestions.length + activeActions.length
|
||||||
|
return messages.map((msg, i) => (
|
||||||
|
<ChatMessage
|
||||||
|
key={i}
|
||||||
|
role={msg.role}
|
||||||
|
content={msg.content}
|
||||||
|
suggestedFlows={msg.suggestedFlows}
|
||||||
|
imageUrls={msg.imageUrls}
|
||||||
|
actionCount={i === lastAssistantIdx && showActionEmphasis ? turnActionCount : undefined}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
})()}
|
||||||
{loading && (
|
{loading && (
|
||||||
<div className="flex gap-3">
|
<div className="flex gap-3">
|
||||||
<div className="w-8 h-8 rounded-full bg-primary/15 flex items-center justify-center">
|
<div className="w-8 h-8 rounded-full bg-primary/15 flex items-center justify-center">
|
||||||
<Sparkles size={14} className="text-primary" />
|
<Sparkles size={14} className="text-primary" />
|
||||||
</div>
|
</div>
|
||||||
<div className="bg-input border border-border rounded-2xl px-4 py-3">
|
<div className="bg-input border border-border rounded-xl px-4 py-3">
|
||||||
<Loader2 size={16} className="animate-spin text-primary" />
|
<Loader2 size={16} className="animate-spin text-primary" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div ref={messagesEndRef} />
|
<div ref={messagesEndRef} />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Phase 8: ProposalBanner — mounted above the composer */}
|
{/* Phase 8: ProposalBanner — mounted above the composer */}
|
||||||
@@ -1988,8 +2042,9 @@ export default function AssistantChatPage() {
|
|||||||
|
|
||||||
{/* Phase 9: InlineNoTemplateDialog — drafted-script evaluation case,
|
{/* Phase 9: InlineNoTemplateDialog — drafted-script evaluation case,
|
||||||
rendered in the chat region above the composer so all three
|
rendered in the chat region above the composer so all three
|
||||||
option cards fit side-by-side without the TaskLane's narrow width. */}
|
option cards fit side-by-side without the TaskLane's narrow width.
|
||||||
{scriptPanelOpen && activeFix && activeChatId && !activeFix.script_template_id && activeFix.ai_drafted_script && (
|
Hidden when the banner is collapsed: the two surfaces are linked. */}
|
||||||
|
{scriptPanelOpen && !bannerCollapsed && activeFix && activeChatId && !activeFix.script_template_id && activeFix.ai_drafted_script && (
|
||||||
<InlineNoTemplateDialog
|
<InlineNoTemplateDialog
|
||||||
fix={activeFix}
|
fix={activeFix}
|
||||||
onClose={() => setScriptPanelOpen(false)}
|
onClose={() => setScriptPanelOpen(false)}
|
||||||
@@ -1998,143 +2053,6 @@ export default function AssistantChatPage() {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Task-lane shortcut chips: visible after the magic-moment
|
|
||||||
dissolves when the task lane has loaded items. Each card
|
|
||||||
links directly to the corresponding diagnostic card in the
|
|
||||||
task lane — clicking opens the lane (if closed) and scrolls
|
|
||||||
to that card. Sourced from actual task lane items, not the
|
|
||||||
AI's free-text suggested_steps, so the card the user lands
|
|
||||||
on has full detail (description, command, etc.). */}
|
|
||||||
{!chipsHidden &&
|
|
||||||
(activeActions.length > 0 || activeQuestions.length > 0) &&
|
|
||||||
magicState === 'dismissed' && (() => {
|
|
||||||
const chipItems = [
|
|
||||||
...activeActions.slice(0, 4).map((a, ai) => ({
|
|
||||||
label: a.label,
|
|
||||||
cardIdx: activeQuestions.length + ai,
|
|
||||||
description: a.description,
|
|
||||||
command: a.command ?? null,
|
|
||||||
type: 'action' as const,
|
|
||||||
})),
|
|
||||||
...activeQuestions.slice(0, Math.max(0, 4 - Math.min(activeActions.length, 4))).map((q, qi) => ({
|
|
||||||
label: q.text,
|
|
||||||
cardIdx: qi,
|
|
||||||
description: q.context ?? null,
|
|
||||||
command: null,
|
|
||||||
type: 'question' as const,
|
|
||||||
})),
|
|
||||||
]
|
|
||||||
const selectedChip = chipItems.find(c => c.cardIdx === selectedChipCardIdx) ?? null
|
|
||||||
return (
|
|
||||||
<div className="px-3 sm:px-6 pt-2 pb-0.5 shrink-0">
|
|
||||||
<div className="max-w-3xl mx-auto">
|
|
||||||
<div className="flex items-center gap-2 mb-1.5">
|
|
||||||
<p className="font-sans text-[0.625rem] uppercase tracking-wider text-muted-foreground">
|
|
||||||
Suggested checks
|
|
||||||
</p>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => { setChipsHidden(true); setSelectedChipCardIdx(null) }}
|
|
||||||
aria-label="Hide suggestions"
|
|
||||||
className="p-0.5 rounded text-muted-foreground hover:text-foreground hover:bg-elevated transition-colors"
|
|
||||||
>
|
|
||||||
<X size={11} />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Inline detail card — shown when a chip is selected */}
|
|
||||||
{selectedChip && (
|
|
||||||
<div className="mb-2 rounded-lg border border-default bg-card p-3 animate-fade-in">
|
|
||||||
<div className="flex items-start justify-between gap-2 mb-1.5">
|
|
||||||
<span className="text-[0.8125rem] font-medium text-heading leading-snug">{selectedChip.label}</span>
|
|
||||||
<button
|
|
||||||
onClick={() => setSelectedChipCardIdx(null)}
|
|
||||||
className="shrink-0 p-0.5 rounded text-muted-foreground hover:text-foreground transition-colors"
|
|
||||||
aria-label="Close detail"
|
|
||||||
>
|
|
||||||
<X size={12} />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
{selectedChip.description && (
|
|
||||||
<p className="text-[0.6875rem] text-muted-foreground mb-2 leading-relaxed">{selectedChip.description}</p>
|
|
||||||
)}
|
|
||||||
{selectedChip.command && (
|
|
||||||
<div className="rounded-md bg-code border border-default/50 px-3 py-2 flex items-start gap-2 mb-2.5">
|
|
||||||
<code className="flex-1 text-[0.75rem] font-mono text-heading whitespace-pre-wrap break-all leading-relaxed">{selectedChip.command}</code>
|
|
||||||
<button
|
|
||||||
onClick={async () => {
|
|
||||||
try {
|
|
||||||
await navigator.clipboard.writeText(selectedChip.command!)
|
|
||||||
} catch {
|
|
||||||
try {
|
|
||||||
const el = document.createElement('textarea')
|
|
||||||
el.value = selectedChip.command!
|
|
||||||
el.style.cssText = 'position:fixed;opacity:0;pointer-events:none'
|
|
||||||
document.body.appendChild(el)
|
|
||||||
el.select()
|
|
||||||
document.execCommand('copy')
|
|
||||||
document.body.removeChild(el)
|
|
||||||
} catch { return }
|
|
||||||
}
|
|
||||||
setCopiedChipCmd(true)
|
|
||||||
setTimeout(() => setCopiedChipCmd(false), 1500)
|
|
||||||
}}
|
|
||||||
className="shrink-0 p-1 rounded text-muted-foreground hover:text-heading hover:bg-elevated transition-colors mt-0.5"
|
|
||||||
title={copiedChipCmd ? 'Copied!' : 'Copy command'}
|
|
||||||
>
|
|
||||||
{copiedChipCmd
|
|
||||||
? <Check size={13} className="text-success" />
|
|
||||||
: <Copy size={13} />
|
|
||||||
}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<button
|
|
||||||
onClick={() => {
|
|
||||||
setSelectedChipCardIdx(null)
|
|
||||||
if (!showTaskLane) setShowTaskLane(true)
|
|
||||||
const el = document.getElementById(`task-lane-card-${selectedChip.cardIdx}`)
|
|
||||||
if (el) {
|
|
||||||
setTimeout(() => el.scrollIntoView({ behavior: 'smooth', block: 'nearest' }), showTaskLane ? 0 : 200)
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
className="flex items-center gap-1 text-[0.75rem] font-medium text-accent-text hover:text-accent transition-colors"
|
|
||||||
>
|
|
||||||
<ArrowRight size={11} />
|
|
||||||
Open in Tasks panel
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="flex gap-2 overflow-x-auto pb-1" style={{ scrollbarWidth: 'none' }}>
|
|
||||||
{chipItems.map((item) => {
|
|
||||||
const isSelected = item.cardIdx === selectedChipCardIdx
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
key={item.cardIdx}
|
|
||||||
type="button"
|
|
||||||
onClick={() => {
|
|
||||||
setCopiedChipCmd(false)
|
|
||||||
setSelectedChipCardIdx(isSelected ? null : item.cardIdx)
|
|
||||||
}}
|
|
||||||
className={cn(
|
|
||||||
'flex items-start gap-2 rounded-lg border px-3 py-2.5 text-left transition-colors shrink-0 w-[172px]',
|
|
||||||
isSelected
|
|
||||||
? 'border-accent/50 bg-accent-dim'
|
|
||||||
: 'border-default bg-card hover:bg-accent-dim hover:border-accent/30',
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<ArrowRight size={12} className="text-accent-text shrink-0 mt-0.5" />
|
|
||||||
<span className="text-xs text-foreground line-clamp-2 leading-snug">{item.label}</span>
|
|
||||||
</button>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
})()}
|
|
||||||
|
|
||||||
{/* Rich Input */}
|
{/* Rich Input */}
|
||||||
<div className="px-3 sm:px-6 py-3 shrink-0">
|
<div className="px-3 sm:px-6 py-3 shrink-0">
|
||||||
<div
|
<div
|
||||||
@@ -2182,7 +2100,7 @@ export default function AssistantChatPage() {
|
|||||||
{upload.preview ? (
|
{upload.preview ? (
|
||||||
<img src={upload.preview} alt="" className="w-full h-full object-cover" />
|
<img src={upload.preview} alt="" className="w-full h-full object-cover" />
|
||||||
) : (
|
) : (
|
||||||
<div className="w-full h-full flex items-center justify-center text-[0.5rem] text-muted-foreground px-1 text-center">
|
<div className="w-full h-full flex items-center justify-center text-[0.625rem] text-muted-foreground px-1 text-center">
|
||||||
{upload.file.name.split('.').pop()?.toUpperCase()}
|
{upload.file.name.split('.').pop()?.toUpperCase()}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -2210,7 +2128,7 @@ export default function AssistantChatPage() {
|
|||||||
{showLogs && (
|
{showLogs && (
|
||||||
<div className="px-4 pb-1">
|
<div className="px-4 pb-1">
|
||||||
<div className="flex items-center justify-between mb-1">
|
<div className="flex items-center justify-between mb-1">
|
||||||
<span className="text-[0.625rem] uppercase tracking-wide text-muted-foreground font-sans">Paste logs or error output</span>
|
<span className="text-[0.625rem] uppercase tracking-wide text-muted-foreground">Paste logs or error output</span>
|
||||||
<button type="button" onClick={() => { setShowLogs(false); setLogContent('') }} className="text-muted-foreground hover:text-foreground"><X size={14} /></button>
|
<button type="button" onClick={() => { setShowLogs(false); setLogContent('') }} className="text-muted-foreground hover:text-foreground"><X size={14} /></button>
|
||||||
</div>
|
</div>
|
||||||
<textarea
|
<textarea
|
||||||
@@ -2350,6 +2268,8 @@ export default function AssistantChatPage() {
|
|||||||
loading={loading}
|
loading={loading}
|
||||||
whatWeKnowSlot={
|
whatWeKnowSlot={
|
||||||
<WhatWeKnow
|
<WhatWeKnow
|
||||||
|
key={activeChatId ?? 'no-session'}
|
||||||
|
sessionId={activeChatId}
|
||||||
facts={facts}
|
facts={facts}
|
||||||
onAddNote={handleAddNote}
|
onAddNote={handleAddNote}
|
||||||
onUpdateFact={handleUpdateFact}
|
onUpdateFact={handleUpdateFact}
|
||||||
@@ -2359,7 +2279,7 @@ export default function AssistantChatPage() {
|
|||||||
}
|
}
|
||||||
bottomSlot={
|
bottomSlot={
|
||||||
<>
|
<>
|
||||||
{scriptPanelOpen && activeFix && activeChatId && activeFix.script_template_id && (
|
{scriptPanelOpen && !bannerCollapsed && activeFix && activeChatId && activeFix.script_template_id && (
|
||||||
<TemplateMatchPanel
|
<TemplateMatchPanel
|
||||||
fix={activeFix}
|
fix={activeFix}
|
||||||
sessionId={activeChatId}
|
sessionId={activeChatId}
|
||||||
@@ -2371,7 +2291,7 @@ export default function AssistantChatPage() {
|
|||||||
<button
|
<button
|
||||||
onClick={() => handleOpenPreview('resolve')}
|
onClick={() => handleOpenPreview('resolve')}
|
||||||
className={cn(
|
className={cn(
|
||||||
'flex items-center gap-1.5 text-[0.75rem] font-medium transition-colors',
|
'flex items-center gap-1.5 text-xs font-medium transition-colors',
|
||||||
previewKind === 'resolve'
|
previewKind === 'resolve'
|
||||||
? 'text-success'
|
? 'text-success'
|
||||||
: 'text-accent-text hover:text-heading',
|
: 'text-accent-text hover:text-heading',
|
||||||
@@ -2383,7 +2303,7 @@ export default function AssistantChatPage() {
|
|||||||
<button
|
<button
|
||||||
onClick={() => handleOpenPreview('escalate')}
|
onClick={() => handleOpenPreview('escalate')}
|
||||||
className={cn(
|
className={cn(
|
||||||
'flex items-center gap-1.5 text-[0.75rem] font-medium transition-colors',
|
'flex items-center gap-1.5 text-xs font-medium transition-colors',
|
||||||
previewKind === 'escalate'
|
previewKind === 'escalate'
|
||||||
? 'text-warning'
|
? 'text-warning'
|
||||||
: 'text-muted-foreground hover:text-heading',
|
: 'text-muted-foreground hover:text-heading',
|
||||||
@@ -2421,6 +2341,8 @@ export default function AssistantChatPage() {
|
|||||||
loading={loading}
|
loading={loading}
|
||||||
whatWeKnowSlot={
|
whatWeKnowSlot={
|
||||||
<WhatWeKnow
|
<WhatWeKnow
|
||||||
|
key={activeChatId ?? 'no-session'}
|
||||||
|
sessionId={activeChatId}
|
||||||
facts={facts}
|
facts={facts}
|
||||||
onAddNote={handleAddNote}
|
onAddNote={handleAddNote}
|
||||||
onUpdateFact={handleUpdateFact}
|
onUpdateFact={handleUpdateFact}
|
||||||
@@ -2430,7 +2352,7 @@ export default function AssistantChatPage() {
|
|||||||
}
|
}
|
||||||
bottomSlot={
|
bottomSlot={
|
||||||
<>
|
<>
|
||||||
{scriptPanelOpen && activeFix && activeChatId && activeFix.script_template_id && (
|
{scriptPanelOpen && !bannerCollapsed && activeFix && activeChatId && activeFix.script_template_id && (
|
||||||
<TemplateMatchPanel
|
<TemplateMatchPanel
|
||||||
fix={activeFix}
|
fix={activeFix}
|
||||||
sessionId={activeChatId}
|
sessionId={activeChatId}
|
||||||
@@ -2442,7 +2364,7 @@ export default function AssistantChatPage() {
|
|||||||
<button
|
<button
|
||||||
onClick={() => handleOpenPreview('resolve')}
|
onClick={() => handleOpenPreview('resolve')}
|
||||||
className={cn(
|
className={cn(
|
||||||
'flex items-center gap-1.5 text-[0.75rem] font-medium transition-colors',
|
'flex items-center gap-1.5 text-xs font-medium transition-colors',
|
||||||
previewKind === 'resolve'
|
previewKind === 'resolve'
|
||||||
? 'text-success'
|
? 'text-success'
|
||||||
: 'text-accent-text hover:text-heading',
|
: 'text-accent-text hover:text-heading',
|
||||||
@@ -2454,7 +2376,7 @@ export default function AssistantChatPage() {
|
|||||||
<button
|
<button
|
||||||
onClick={() => handleOpenPreview('escalate')}
|
onClick={() => handleOpenPreview('escalate')}
|
||||||
className={cn(
|
className={cn(
|
||||||
'flex items-center gap-1.5 text-[0.75rem] font-medium transition-colors',
|
'flex items-center gap-1.5 text-xs font-medium transition-colors',
|
||||||
previewKind === 'escalate'
|
previewKind === 'escalate'
|
||||||
? 'text-warning'
|
? 'text-warning'
|
||||||
: 'text-muted-foreground hover:text-heading',
|
: 'text-muted-foreground hover:text-heading',
|
||||||
@@ -2552,7 +2474,7 @@ export default function AssistantChatPage() {
|
|||||||
{/* Handoff context overlay — re-opened from the toolbar */}
|
{/* Handoff context overlay — re-opened from the toolbar */}
|
||||||
{overlayHandoff && (
|
{overlayHandoff && (
|
||||||
<div
|
<div
|
||||||
className="fixed inset-0 z-50 flex items-start justify-center overflow-y-auto bg-black/60 backdrop-blur-sm p-4 sm:p-8 animate-fade-in"
|
className="fixed inset-0 z-50 flex items-start justify-center overflow-y-auto bg-black/70 p-4 sm:p-8 animate-fade-in"
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
if (e.target === e.currentTarget) setOverlayHandoff(null)
|
if (e.target === e.currentTarget) setOverlayHandoff(null)
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ export function MyTreesPage() {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadMyTrees()
|
loadMyTrees()
|
||||||
}, [user?.id])
|
}, [user?.id]) // eslint-disable-line react-hooks/exhaustive-deps -- reload only when the owner identity changes
|
||||||
|
|
||||||
const loadMyTrees = async () => {
|
const loadMyTrees = async () => {
|
||||||
if (!user?.id) return
|
if (!user?.id) return
|
||||||
|
|||||||
@@ -118,7 +118,7 @@ export function ProceduralEditorPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return () => { reset() }
|
return () => { reset() }
|
||||||
}, [id])
|
}, [id]) // eslint-disable-line react-hooks/exhaustive-deps -- editor init is keyed to route id; store actions are stable
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
useProceduralEditorStore.getState().validate()
|
useProceduralEditorStore.getState().validate()
|
||||||
|
|||||||
@@ -155,7 +155,7 @@ export function ProceduralNavigationPage() {
|
|||||||
return () => {
|
return () => {
|
||||||
if (timerRef.current) clearInterval(timerRef.current)
|
if (timerRef.current) clearInterval(timerRef.current)
|
||||||
}
|
}
|
||||||
}, [treeId])
|
}, [treeId]) // eslint-disable-line react-hooks/exhaustive-deps -- session load is keyed to route tree id
|
||||||
|
|
||||||
// Check for PSA connection on mount
|
// Check for PSA connection on mount
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
@@ -57,7 +57,7 @@ export function SessionDetailPage() {
|
|||||||
if (id) {
|
if (id) {
|
||||||
loadSession()
|
loadSession()
|
||||||
}
|
}
|
||||||
}, [id])
|
}, [id]) // eslint-disable-line react-hooks/exhaustive-deps -- detail reload is keyed to route session id
|
||||||
|
|
||||||
// Auto-show rating modal for completed sessions with library steps
|
// Auto-show rating modal for completed sessions with library steps
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
@@ -269,7 +269,7 @@ export default function SessionHistoryPage() {
|
|||||||
<PageMeta title="Sessions" />
|
<PageMeta title="Sessions" />
|
||||||
<div className="container mx-auto px-4 py-6 sm:px-6 sm:py-8">
|
<div className="container mx-auto px-4 py-6 sm:px-6 sm:py-8">
|
||||||
{/* Page heading */}
|
{/* Page heading */}
|
||||||
<div className="mb-6">
|
<div className="mb-6" data-testid="session-history-heading">
|
||||||
<h1 className="text-2xl font-heading font-bold text-foreground sm:text-3xl">Session History</h1>
|
<h1 className="text-2xl font-heading font-bold text-foreground sm:text-3xl">Session History</h1>
|
||||||
<p className="mt-1 text-sm text-muted-foreground">View and manage your sessions</p>
|
<p className="mt-1 text-sm text-muted-foreground">View and manage your sessions</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -279,6 +279,7 @@ export default function SessionHistoryPage() {
|
|||||||
{TABS.map((tab) => (
|
{TABS.map((tab) => (
|
||||||
<button
|
<button
|
||||||
key={tab.id}
|
key={tab.id}
|
||||||
|
data-testid={`session-history-tab-${tab.id}`}
|
||||||
onClick={() => setActiveTab(tab.id)}
|
onClick={() => setActiveTab(tab.id)}
|
||||||
className={cn(
|
className={cn(
|
||||||
'px-4 py-2 text-sm transition-colors whitespace-nowrap',
|
'px-4 py-2 text-sm transition-colors whitespace-nowrap',
|
||||||
@@ -614,6 +615,7 @@ export default function SessionHistoryPage() {
|
|||||||
Close
|
Close
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
|
data-testid="flow-session-resume"
|
||||||
onClick={() => navigate(getSessionResumePath(session.tree_id, session.tree_snapshot?.tree_type), { state: { sessionId: session.id } })}
|
onClick={() => navigate(getSessionResumePath(session.tree_id, session.tree_snapshot?.tree_type), { state: { sessionId: session.id } })}
|
||||||
className="rounded-md bg-primary px-3 py-2 text-sm font-medium text-white hover:brightness-110 transition-all"
|
className="rounded-md bg-primary px-3 py-2 text-sm font-medium text-white hover:brightness-110 transition-all"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -234,7 +234,7 @@ export function TreeEditorPage() {
|
|||||||
return () => {
|
return () => {
|
||||||
reset()
|
reset()
|
||||||
}
|
}
|
||||||
}, [id, isEditMode, canCreateTrees])
|
}, [id, isEditMode, canCreateTrees]) // eslint-disable-line react-hooks/exhaustive-deps -- initialization is keyed to route/editability state
|
||||||
|
|
||||||
// Handle unsaved changes warning
|
// Handle unsaved changes warning
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -391,7 +391,7 @@ export function TreeEditorPage() {
|
|||||||
} finally {
|
} finally {
|
||||||
setSaving(false)
|
setSaving(false)
|
||||||
}
|
}
|
||||||
}, [isSaving, isEditMode, id, editorMode, getTreeForSave, markSaved, navigate])
|
}, [isSaving, isEditMode, id, editorMode, getTreeForSave, markSaved, navigate, setSaving])
|
||||||
|
|
||||||
const handlePublish = useCallback(async () => {
|
const handlePublish = useCallback(async () => {
|
||||||
if (isSaving) return
|
if (isSaving) return
|
||||||
@@ -472,7 +472,7 @@ export function TreeEditorPage() {
|
|||||||
} finally {
|
} finally {
|
||||||
setSaving(false)
|
setSaving(false)
|
||||||
}
|
}
|
||||||
}, [isSaving, isEditMode, id, editorMode, validate, getTreeForSave, markSaved, navigate])
|
}, [isSaving, isEditMode, id, editorMode, validate, getTreeForSave, markSaved, navigate, setSaving])
|
||||||
|
|
||||||
// Keep handleSave for backward compatibility (Ctrl+S shortcut)
|
// Keep handleSave for backward compatibility (Ctrl+S shortcut)
|
||||||
const handleSave = useCallback(async () => {
|
const handleSave = useCallback(async () => {
|
||||||
|
|||||||
@@ -292,7 +292,7 @@ export function TreeNavigationPage() {
|
|||||||
if (treeId) {
|
if (treeId) {
|
||||||
loadTreeAndSession()
|
loadTreeAndSession()
|
||||||
}
|
}
|
||||||
}, [treeId])
|
}, [treeId]) // eslint-disable-line react-hooks/exhaustive-deps -- route tree id is the load boundary
|
||||||
|
|
||||||
// Check for PSA connection on mount
|
// Check for PSA connection on mount
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
Reference in New Issue
Block a user