harryagasi commited on
Commit
3baad7e
·
1 Parent(s): b5e46e4

[NOTICKET] feat(knowledge,analysis): add deletion confirmation, duplicate-name guard, and unusable-analysis lockout

Browse files

- Require confirmation with an impact preview (affected analyses) before
deleting a knowledge source or clearing all documents in KnowledgeManagement
- Block creating analyses with a name that duplicates an existing one
(case/whitespace-insensitive) in NewAnalysisDialog
- Derive an "unusable" state from the existing staleSources check and disable
chat input/send/help, suggested questions, generate-report, and the
source-rebind controls in AnalysisHeader once a bound source is deleted,
while keeping chat history and reports viewable

.specify/feature.json CHANGED
@@ -1 +1,3 @@
1
- {"feature_directory": "specs/002-fix-analysis-chat-ui"}
 
 
 
1
+ {
2
+ "feature_directory": "specs/003-knowledge-delete-guardrails"
3
+ }
CLAUDE.md CHANGED
@@ -1,6 +1,6 @@
1
  <!-- SPECKIT START -->
2
  For additional context about technologies to be used, project structure,
3
  shell commands, and other important information, see:
4
- - Implementation Plan: specs/002-fix-analysis-chat-ui/plan.md
5
- - Feature Specification: specs/002-fix-analysis-chat-ui/spec.md
6
  <!-- SPECKIT END -->
 
1
  <!-- SPECKIT START -->
2
  For additional context about technologies to be used, project structure,
3
  shell commands, and other important information, see:
4
+ - Implementation Plan: specs/003-knowledge-delete-guardrails/plan.md
5
+ - Feature Specification: specs/003-knowledge-delete-guardrails/spec.md
6
  <!-- SPECKIT END -->
specs/003-knowledge-delete-guardrails/checklists/requirements.md ADDED
@@ -0,0 +1,35 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Specification Quality Checklist: Knowledge Source Deletion Guardrails
2
+
3
+ **Purpose**: Validate specification completeness and quality before proceeding to planning
4
+ **Created**: 2026-07-24
5
+ **Feature**: [spec.md](../spec.md)
6
+
7
+ ## Content Quality
8
+
9
+ - [x] No implementation details (languages, frameworks, APIs)
10
+ - [x] Focused on user value and business needs
11
+ - [x] Written for non-technical stakeholders
12
+ - [x] All mandatory sections completed
13
+
14
+ ## Requirement Completeness
15
+
16
+ - [x] No [NEEDS CLARIFICATION] markers remain
17
+ - [x] Requirements are testable and unambiguous
18
+ - [x] Success criteria are measurable
19
+ - [x] Success criteria are technology-agnostic (no implementation details)
20
+ - [x] All acceptance scenarios are defined
21
+ - [x] Edge cases are identified
22
+ - [x] Scope is clearly bounded
23
+ - [x] Dependencies and assumptions identified
24
+
25
+ ## Feature Readiness
26
+
27
+ - [x] All functional requirements have clear acceptance criteria
28
+ - [x] User scenarios cover primary flows
29
+ - [x] Feature meets measurable outcomes defined in Success Criteria
30
+ - [x] No implementation details leak into specification
31
+
32
+ ## Notes
33
+
34
+ - No clarifications were required: reasonable defaults were assumed for near-duplicate name matching, "unusable" state semantics, and re-binding being out of scope. These are documented in the Assumptions section of spec.md.
35
+ - All checklist items pass on first validation pass.
specs/003-knowledge-delete-guardrails/contracts/components.md ADDED
@@ -0,0 +1,119 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Component Contracts: Knowledge Source Deletion Guardrails
2
+
3
+ This is a frontend-only feature with no backend/API contract changes. The contracts below describe the new/changed React component prop interfaces so implementation and review have a shared reference.
4
+
5
+ ## `src/app/components/ui/alert-dialog.tsx` (new)
6
+
7
+ Thin shadcn-style wrapper around `@radix-ui/react-alert-dialog`, exporting: `AlertDialog`, `AlertDialogTrigger`, `AlertDialogContent`, `AlertDialogHeader`, `AlertDialogTitle`, `AlertDialogDescription`, `AlertDialogFooter`, `AlertDialogAction`, `AlertDialogCancel` — same shape as the standard shadcn/ui `alert-dialog` primitive. No project-specific props beyond styling.
8
+
9
+ ## `KnowledgeManagement.tsx` — internal confirmation dialog state (no new exported props)
10
+
11
+ New internal state:
12
+
13
+ ```ts
14
+ type DeleteIntent =
15
+ | { kind: "document"; docId: string; docName: string }
16
+ | { kind: "database"; clientId: string; clientName: string }
17
+ | { kind: "clear-all-documents" };
18
+
19
+ const [deleteIntent, setDeleteIntent] = useState<DeleteIntent | null>(null);
20
+ const [impact, setImpact] = useState<{ id: string; analysis_title: string }[]>([]);
21
+ const [loadingImpact, setLoadingImpact] = useState(false);
22
+ ```
23
+
24
+ Behavior contract:
25
+ - Clicking any delete trigger (single document, single database client, "clear all documents") sets `deleteIntent` instead of calling the delete API directly, then fetches `listAnalyses()` and computes `impact` (see data-model.md `DeletionImpactSummary`).
26
+ - The `AlertDialog` renders open whenever `deleteIntent !== null`.
27
+ - `AlertDialogCancel` → `setDeleteIntent(null); setImpact([])` — no API call.
28
+ - `AlertDialogAction` → performs the existing delete call(s) (`deleteDocument` / `deleteDatabaseClient`, looped for clear-all) that today run directly from the click handler, then closes the dialog.
29
+ - Replaces the existing `window.confirm(...)` call in `deleteAllDocuments` (`KnowledgeManagement.tsx:266`) — that function becomes the `AlertDialogAction` handler for the `clear-all-documents` intent.
30
+
31
+ ## `NewAnalysisDialog.tsx`
32
+
33
+ New/changed props:
34
+
35
+ ```ts
36
+ interface NewAnalysisDialogProps {
37
+ open: boolean;
38
+ onClose: () => void;
39
+ onCreated: (analysis: Analysis) => void;
40
+ existingAnalyses: Analysis[]; // NEW — passed from AnalysisShell's existing `analyses` state
41
+ }
42
+ ```
43
+
44
+ Behavior contract:
45
+ - `canSubmit` gains an additional condition: `!isDuplicateName(title, existingAnalyses)` (see data-model.md).
46
+ - When the title is a duplicate, render an inline error near the title field (same visual pattern as the existing `error` state block) stating the name is already in use; this does not block typing, only submission.
47
+ - Caller (`AnalysisShell.tsx`) passes its existing `analyses` array — no new fetch required.
48
+
49
+ ## `SuggestedQuestionsBar.tsx`
50
+
51
+ New prop:
52
+
53
+ ```ts
54
+ interface SuggestedQuestionsBarProps {
55
+ questions: string[];
56
+ onSelect: (question: string) => void;
57
+ disabled?: boolean; // NEW
58
+ }
59
+ ```
60
+
61
+ Behavior contract: when `disabled` is `true`, every rendered question button gets `disabled` and the existing click handler becomes a no-op (defense in depth beyond the `disabled` HTML attribute).
62
+
63
+ ## `ReportSidebar.tsx`
64
+
65
+ New prop:
66
+
67
+ ```ts
68
+ interface ReportSidebarProps {
69
+ analysis: Analysis;
70
+ userId?: string;
71
+ onCollapse?: () => void;
72
+ disabled?: boolean; // NEW — true when the analysis is unusable
73
+ }
74
+ ```
75
+
76
+ Behavior contract: the generate-report button (`handleGenerate`) is disabled when `disabled` is `true`; report viewing/history (existing report list/version selector) is unaffected and remains fully interactive.
77
+
78
+ ## `DataBindSelector.tsx`
79
+
80
+ New prop:
81
+
82
+ ```ts
83
+ interface DataBindSelectorProps {
84
+ value: DataBindItem[];
85
+ onChange: (items: DataBindItem[]) => void;
86
+ disabled?: boolean; // meaning extended: also true when hosting analysis is unusable, not just `submitting`
87
+ }
88
+ ```
89
+
90
+ Behavior contract: when used inside an unusable analysis's edit/update-data flow (specifically the instance embedded in `AnalysisHeader.tsx`'s source editor), `disabled` is forced `true` regardless of other state (e.g. `saving`), preventing `updateAnalysisDataBind` from being invoked. Per clarification, this applies even though this is the same control a user would otherwise use to fix the stale binding — there is no exception for "trying to recover."
91
+
92
+ ## `AnalysisShell.tsx` — orchestration (no new exported props; internal only)
93
+
94
+ New internal derivation, reusing the **already-existing** `staleSources` state (`AnalysisShell.tsx:80`, computed at `AnalysisShell.tsx:117-138`) rather than recomputing it:
95
+
96
+ ```ts
97
+ const isActiveAnalysisUnusable = staleSources.length > 0;
98
+ ```
99
+
100
+ This value is passed as the new `disabled`/`unusable` prop into `ChatInput`, `HelpSkillButton` (via `ChatInput`'s existing `disabled` passthrough), `SuggestedQuestionsBar`, `ReportSidebar`, and the new `unusable` prop on `AnalysisHeader` (below). FR-010's visible indicator reuses `AnalysisHeader.tsx`'s existing stale-sources banner rather than adding a new one.
101
+
102
+ ## `AnalysisHeader.tsx`
103
+
104
+ New prop:
105
+
106
+ ```ts
107
+ interface AnalysisHeaderProps {
108
+ analysis: Analysis | null;
109
+ staleSources?: DataBindItem[];
110
+ onUpdateDataBind: (items: DataBindItem[]) => Promise<void>;
111
+ unusable?: boolean; // NEW — equivalent to staleSources.length > 0, passed explicitly for clarity at call sites
112
+ }
113
+ ```
114
+
115
+ Behavior contract (per clarification — no in-app recovery path):
116
+ - When `unusable` is `true`, the header's "N sources" button (`openSourceEditor`, ~line 40) is disabled so the editor cannot be opened.
117
+ - The existing stale-sources banner's "Update binding" button (~line 75) is disabled (not just visually — the `onClick` that sets `editingSources(true)` must be prevented).
118
+ - If `editingSources` is somehow already `true` when `unusable` becomes true, the embedded `DataBindSelector` (~line 91) and the "Save sources" button (~line 97) are forced `disabled`.
119
+ - The banner copy may be extended (not replaced) to also convey "this analysis can no longer be used" per FR-010, reusing this single banner for both the pre-existing "stale sources" warning and this feature's "unusable" indicator.
specs/003-knowledge-delete-guardrails/data-model.md ADDED
@@ -0,0 +1,49 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Data Model: Knowledge Source Deletion Guardrails
2
+
3
+ No new persisted entities or backend schema changes. This feature only adds client-side derived values computed from existing `orchestrationApi` types (`src/services/orchestrationApi.ts`).
4
+
5
+ ## Existing entities reused (unchanged)
6
+
7
+ - **`ApiDocument`** (`orchestrationApi.ts:60`-ish): `{ id, filename, file_type, file_size, status, created_at, ... }` — a document knowledge source.
8
+ - **`DatabaseClient`**: `{ id, name, db_type, status, ... }` — a database knowledge source.
9
+ - **`DataBindItem`** (`orchestrationApi.ts:150`): `{ id, name, group_type: "document" | "database", type }` — a reference from an `Analysis` to a bound knowledge source.
10
+ - **`Analysis`** (`orchestrationApi.ts:157`): `{ id, analysis_title, objective, business_questions, status: "active" | "inactive" | string, data_bind: DataBindItem[], data_bind_version, ... }`. `status` is **not** modified by this feature (see Clarifications in spec.md).
11
+
12
+ ## New client-side derived values (not persisted, not sent to the backend)
13
+
14
+ ### `DeletionImpactSummary`
15
+
16
+ Computed at delete-intent time in `KnowledgeManagement.tsx`, before the confirmation dialog opens.
17
+
18
+ | Field | Type | Description |
19
+ |---|---|---|
20
+ | `sourceIds` | `string[]` | The knowledge source id(s) about to be deleted (one for single delete, many for "clear all"). |
21
+ | `affectedAnalyses` | `{ id: string; analysis_title: string }[]` | Analyses whose `data_bind` contains at least one of `sourceIds`, deduplicated by analysis id. Derived by fetching `listAnalyses()` and filtering `analysis.data_bind.some(item => sourceIds.includes(item.id))`. |
22
+
23
+ **Lifecycle**: Computed fresh each time a delete action is initiated; discarded when the dialog closes (confirm or cancel). Never persisted.
24
+
25
+ ### Analysis usability flag (`isUnusable`)
26
+
27
+ **Reused, not newly computed**: `AnalysisShell.tsx` already maintains `staleSources: DataBindItem[]` (state at `AnalysisShell.tsx:80`, computed in the `useEffect` at `AnalysisShell.tsx:117-138`) by cross-referencing `activeAnalysis.data_bind` against live `getDocuments`/`getDatabaseClients` results — this is exactly the "unusable" derivation this feature needs, built for feature 002's stale-sources banner. This feature derives from it rather than re-implementing it:
28
+
29
+ ```
30
+ isActiveAnalysisUnusable: boolean = staleSources.length > 0
31
+ ```
32
+
33
+ - Any single missing bound source is sufficient to mark the whole analysis unusable (per spec Assumptions / Acceptance Scenario US3.6) — already true of `staleSources`'s existing filter logic.
34
+ - This is a pure, stateless derivation — not stored on the `Analysis` object returned by the API, and never written back via `updateAnalysis`/`UpdateAnalysisPayload`.
35
+ - Recomputed on-demand (page load / analysis selection), not via a live subscription (per research.md Unknown 3 and the plan's Constraints) — matching `staleSources`'s existing recompute trigger (the `activeAnalysis`-keyed effect).
36
+
37
+ **State transitions**: `isActiveAnalysisUnusable` has no explicit transition function in this feature — it is a pure function of current data. It can only become `true` when a bound source is deleted (User Story 1 → 3). Per clarification, this feature has **no** transition back to `false`: once `staleSources.length > 0`, the update-bound-data action (including the pre-existing "Update binding" control that would normally clear `staleSources`) is itself disabled by this feature (FR-011/FR-014), so there is no in-app path to restore usability.
38
+
39
+ ## Validation rule: analysis name uniqueness
40
+
41
+ Enforced client-side in `NewAnalysisDialog.tsx` before calling `createAnalysis()`:
42
+
43
+ ```
44
+ isDuplicateName(candidate: string, existing: Analysis[]): boolean =
45
+ existing.some(a => a.analysis_title.trim().toLowerCase() === candidate.trim().toLowerCase())
46
+ ```
47
+
48
+ - Requires the dialog to have access to the current analysis list (already loaded by the parent `AnalysisShell.tsx` as `analyses` state) — passed down as a new prop, or fetched via `listAnalyses()` if the dialog is opened without it in scope.
49
+ - This is a client-side guard only; per FR-007/FR-008 no backend contract change is assumed to exist, so this check does not guarantee uniqueness under concurrent creation from two sessions (acceptable per spec — no server-side uniqueness constraint was requested).
specs/003-knowledge-delete-guardrails/plan.md ADDED
@@ -0,0 +1,80 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Implementation Plan: Knowledge Source Deletion Guardrails
2
+
3
+ **Branch**: `003-knowledge-delete-guardrails` | **Date**: 2026-07-24 | **Spec**: [spec.md](./spec.md)
4
+
5
+ **Input**: Feature specification from `/specs/003-knowledge-delete-guardrails/spec.md`
6
+
7
+ **Note**: This template is filled in by the `/speckit-plan` command. See `.specify/templates/plan-template.md` for the execution workflow.
8
+
9
+ ## Summary
10
+
11
+ Add a confirmation dialog to `KnowledgeManagement.tsx` for every document/database delete action (single delete and "clear all documents") that lists which analyses are bound to the source(s) being removed and warns that those analyses will stop working; add a client-side exact-match (case/whitespace-insensitive) duplicate-name guard to `NewAnalysisDialog.tsx`; and derive a client-side "unusable" state for any analysis whose `data_bind` references a knowledge source that no longer exists, reusing the existing `staleSources` computation already present in `AnalysisShell.tsx`/`AnalysisHeader.tsx` (built for a "some bound sources are no longer available" warning banner) rather than introducing a parallel computation. That derived state is propagated to disable `ChatInput`, `HelpSkillButton`, `SuggestedQuestionsBar`, the report-generation action in `ReportSidebar.tsx`, and — per clarification — the data-bind-update action in `DataBindSelector.tsx` **including the pre-existing "Update binding" recovery control** in `AnalysisHeader.tsx`'s stale-sources banner, so an unusable analysis has no in-app path back to usable; `MessageList.tsx` and existing report viewing remain fully readable. Per clarification, the unusable flag is a standalone derived value — it does **not** reuse or write to the existing `Analysis.status` ("active"/"inactive") field, since that field already drives the "active" filter used to fetch the sidebar analysis list and must not be touched. All changes are frontend-only (React 18 + TypeScript + Tailwind); no new backend endpoints are required — the impact computation and the unusable-state derivation both run client-side against data already available from `listAnalyses()`, `getDocuments()`, and `getDatabaseClients()`.
12
+
13
+ ## Technical Context
14
+
15
+ **Language/Version**: TypeScript (React 18.3.1, Vite 6.3.5)
16
+
17
+ **Primary Dependencies**: React 18, Tailwind CSS 4, `@radix-ui/react-alert-dialog` (already installed, unused so far — used for the new confirmation dialog), `lucide-react` icons, `sonner` for toasts
18
+
19
+ **Storage**: N/A — this feature only touches client-side UI state and read-only client-side derivations; `Analysis`/`ApiDocument`/`DatabaseClient` data continues to come from the existing `orchestrationApi` service unchanged, no new persisted fields
20
+
21
+ **Testing**: No test framework is currently configured in this project (no vitest/jest in `devDependencies`); verification is manual/visual, consistent with `specs/002-fix-analysis-chat-ui/plan.md`
22
+
23
+ **Target Platform**: Web browser (desktop primary; existing `KnowledgeManagement` modal/page variants and `AnalysisShell` mobile drawer must keep working)
24
+
25
+ **Project Type**: Single-page web application (Vite + React), frontend-only change — no `backend/` directory exists in this repo
26
+
27
+ **Performance Goals**: Impact-preview computation (matching a source's id against every analysis's `data_bind`) must resolve within 300ms for typical catalogs (dozens of analyses) so the confirmation dialog opens without a visible delay (SC-002)
28
+
29
+ **Constraints**: Unusable state is a standalone derived flag, independent of and never written into `Analysis.status` (per clarification — `status` already filters the sidebar's `listAnalyses({ status: "active" })` call and must not be repurposed); unusable state is recomputed on analysis load/selection (not real-time/live while a user is idle on the page, since no push/websocket mechanism exists in this codebase); deletion remains a soft warning, not a hard block — the confirm action always proceeds once acknowledged (FR-006); once an analysis is flagged unusable it has **no in-app recovery path** — the update-bound-data action is disabled unconditionally, including the pre-existing "Update binding" control in `AnalysisHeader.tsx`'s stale-sources banner (per clarification)
30
+
31
+ **Scale/Scope**: 5 components modified (`KnowledgeManagement.tsx` for the confirmation dialog, `NewAnalysisDialog.tsx` for duplicate-name validation, `AnalysisShell.tsx` for computing/propagating the unusable flag, `ReportSidebar.tsx` and `DataBindSelector.tsx` for disabling report-generation/data-update, `SuggestedQuestionsBar.tsx` for adding a `disabled` prop it currently lacks); no new routes, no new backend endpoints, no new persisted entities
32
+
33
+ ## Constitution Check
34
+
35
+ *GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.*
36
+
37
+ The project constitution (`.specify/memory/constitution.md`) is an unfilled template with no ratified principles — there are no constitutional gates to evaluate against. No violations to justify.
38
+
39
+ ## Project Structure
40
+
41
+ ### Documentation (this feature)
42
+
43
+ ```text
44
+ specs/003-knowledge-delete-guardrails/
45
+ ├── plan.md # This file (/speckit-plan command output)
46
+ ├── research.md # Phase 0 output (/speckit-plan command)
47
+ ├── data-model.md # Phase 1 output (/speckit-plan command)
48
+ ├── quickstart.md # Phase 1 output (/speckit-plan command)
49
+ ├── contracts/ # Phase 1 output (/speckit-plan command) — UI component contracts only
50
+ └── tasks.md # Phase 2 output (/speckit-tasks command - NOT created by /speckit-plan)
51
+ ```
52
+
53
+ ### Source Code (repository root)
54
+
55
+ ```text
56
+ src/
57
+ ├── app/
58
+ │ └── components/
59
+ │ ├── KnowledgeManagement.tsx # Delete/clear-all handlers — touched for US1 (confirmation dialog + impact preview)
60
+ │ └── analysis/
61
+ │ ├── AnalysisShell.tsx # Derives `isActiveAnalysisUnusable` from existing `staleSources`; wires disabled props — touched for US3
62
+ │ ├── AnalysisHeader.tsx # Existing stale-sources banner + "Update binding" control — touched for US3 (reuse banner for FR-010; disable "Update binding"/"N sources" controls and embedded DataBindSelector when unusable)
63
+ │ ├── NewAnalysisDialog.tsx # Title field validation — touched for US2 (duplicate-name guard)
64
+ │ ├── ChatInput.tsx # Already supports `disabled` — no change, just wired from US3
65
+ │ ├── HelpSkillButton.tsx # Already supports `disabled` — no change, just wired from US3
66
+ │ ├── SuggestedQuestionsBar.tsx # Touched for US3 (add `disabled` prop, currently missing)
67
+ │ ├── ReportSidebar.tsx # `handleGenerate`/generate-report button — touched for US3 (disable when unusable)
68
+ │ ├── DataBindSelector.tsx # Update-bound-data control (already supports `disabled`) — touched for US3 (forced disabled when unusable, both in AnalysisHeader's editor and in the "Update binding" trigger)
69
+ │ └── ui/
70
+ │ └── alert-dialog.tsx # New shadcn/radix-style wrapper around the already-installed
71
+ │ # @radix-ui/react-alert-dialog primitive — reused for US1's confirmation dialog
72
+ └── services/
73
+ └── orchestrationApi.ts # Unchanged; existing Analysis/DataBindItem/ApiDocument/DatabaseClient types reused
74
+ ```
75
+
76
+ **Structure Decision**: Single frontend project (`src/app/components/` and `src/app/components/analysis/`) — no new top-level directories. All work happens within the existing Knowledge and Analysis feature folders, reusing the already-installed `@radix-ui/react-alert-dialog` (wrapped once as `src/app/components/ui/alert-dialog.tsx`, matching the existing shadcn-style pattern of `ui/resizable.tsx`) rather than adding a new dependency, and computing the impact list / unusable flag entirely client-side from data already returned by `listAnalyses()`, `getDocuments()`, and `getDatabaseClients()`.
77
+
78
+ ## Complexity Tracking
79
+
80
+ > No Constitution Check violations — this section is not applicable.
specs/003-knowledge-delete-guardrails/quickstart.md ADDED
@@ -0,0 +1,42 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Quickstart: Verifying Knowledge Source Deletion Guardrails
2
+
3
+ Manual verification steps (no test framework configured in this project — see plan.md Testing).
4
+
5
+ ## Prerequisites
6
+
7
+ - Run the app: `pnpm dev` (or `npm run dev` per `package.json` scripts) and sign in with a user that has at least:
8
+ - One document knowledge source bound to at least one analysis
9
+ - One database knowledge source not bound to any analysis
10
+ - Two existing analyses with distinct names
11
+
12
+ ## User Story 1 — Deletion confirmation with impact preview
13
+
14
+ 1. Open the Knowledge menu, find a document bound to an analysis, click its delete icon.
15
+ 2. Verify a confirmation dialog opens (not a browser `confirm()` popup) listing the bound analysis by name and a warning that it will become unusable.
16
+ 3. Click Cancel — verify the document is still listed and no analysis state changed.
17
+ 4. Click delete again, click Confirm — verify the document disappears from the list.
18
+ 5. Delete a database source with **no** bound analyses — verify the dialog still appears but states no analyses will be affected.
19
+ 6. Click "Clear all" on documents with multiple docs (some bound to analyses) — verify the dialog lists the combined, deduplicated set of affected analyses.
20
+
21
+ ## User Story 2 — Duplicate analysis name guard
22
+
23
+ 1. Open "New Analysis", enter a title that exactly matches an existing analysis's title (try exact, then with different case/leading spaces).
24
+ 2. Verify submission is blocked and an inline message indicates the name is already in use.
25
+ 3. Change the title to something unique — verify the create button becomes usable and creation succeeds.
26
+ 4. Repeat step 1 with a different set of bound knowledge sources selected — verify the duplicate-name check still blocks submission the same way (FR-008).
27
+
28
+ ## User Story 3 — Unusable analysis after source deletion
29
+
30
+ 1. From User Story 1 step 4, open the analysis that was bound to the now-deleted document.
31
+ 2. Verify the existing "some bound sources are no longer available" banner is shown and now also states the analysis can no longer be used (this feature extends that banner rather than adding a new one).
32
+ 3. Verify: chat input textarea, Send button, Help button, and every suggested/recommended question button are disabled.
33
+ 4. Verify: the "Generate report" action is disabled.
34
+ 5. Verify: the "N sources" button and the banner's "Update binding" button are both disabled — clicking them does nothing, and the bound-data editor cannot be opened at all. This is intentional: per product decision, an unusable analysis has no in-app way to recover by re-binding a replacement source.
35
+ 6. Verify: existing chat messages are still visible/scrollable, and any previously generated report can still be opened and read.
36
+ 7. Try pressing Enter in the (disabled) chat textarea — verify no message is sent.
37
+
38
+ ## Regression checks
39
+
40
+ - An analysis with no bound sources at all still behaves exactly as before (not flagged unusable).
41
+ - An analysis with multiple bound sources where only one was deleted is still flagged unusable (any single missing source is sufficient).
42
+ - The Analysis sidebar list (`listAnalyses({ status: "active" })`) still shows the unusable analysis — confirming `status` was not repurposed/changed by this feature.
specs/003-knowledge-delete-guardrails/research.md ADDED
@@ -0,0 +1,32 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Research: Knowledge Source Deletion Guardrails
2
+
3
+ ## Unknown 1: How to compute "which analyses are affected" by deleting a source
4
+
5
+ **Decision**: On delete-intent (before showing the confirmation dialog), call `listAnalyses()` (existing `orchestrationApi` function, already used in `AnalysisShell.tsx`) to get the current analysis set, then filter client-side for analyses whose `data_bind: DataBindItem[]` array contains an item whose `id` matches the source(s) being deleted.
6
+
7
+ **Rationale**: There is no dedicated "which analyses use source X" backend endpoint. `Analysis.data_bind` (`src/services/orchestrationApi.ts:164`) already embeds the bound source refs (`{ id, name, group_type, type }`) per analysis, so the join is cheap to do client-side and requires no new API contract. `KnowledgeManagement.tsx` does not currently import `listAnalyses`, so this is a new cross-feature dependency (Knowledge → Analysis service) but stays within the existing `orchestrationApi` module — no new service needed.
8
+
9
+ **Alternatives considered**:
10
+ - New backend endpoint returning source→analyses reverse index: rejected as unnecessary backend work for a computation the client can already do from data it fetches anyway (dozens of analyses is trivial to filter in-browser, matching the plan's <300ms budget).
11
+ - Precomputing/caching the reverse index in a global store: rejected as premature — `listAnalyses()` is called fresh at KnowledgeManagement delete-intent time; no evidence of a global state library (Redux/Zustand) in this codebase to justify one.
12
+
13
+ ## Unknown 2: Confirmation dialog component to use
14
+
15
+ **Decision**: Use `@radix-ui/react-alert-dialog` (already in `package.json` dependencies, currently unused anywhere in `src/`), wrapped once as `src/app/components/ui/alert-dialog.tsx` in the same shadcn-style pattern as the existing `src/app/components/ui/resizable.tsx` wrapper (per `specs/002-fix-analysis-chat-ui/plan.md` precedent of reusing already-installed-but-unused Radix primitives instead of adding new dependencies).
16
+
17
+ **Rationale**: `AlertDialog` (vs. plain `Dialog`) is the Radix-recommended primitive specifically for "interrupt the user with imperative content and expect a response" confirmation flows — matches this feature's cancel/confirm requirement (FR-005/FR-006) and provides built-in focus-trap/escape/backdrop behavior the current `window.confirm` in `deleteAllDocuments` (`KnowledgeManagement.tsx:266`) lacks styling control over.
18
+
19
+ **Alternatives considered**:
20
+ - Keep using `window.confirm`: rejected — cannot render a formatted, scrollable list of affected analysis names (FR-002), which the current single-string browser confirm cannot do.
21
+ - `@radix-ui/react-dialog` (already used elsewhere for `NewAnalysisDialog`'s manual overlay, though that component hand-rolls its own overlay rather than importing the Radix dialog primitive): rejected in favor of `AlertDialog` for the confirmation-specific semantics and to keep a single reusable primitive for this exact "are you sure" pattern.
22
+
23
+ ## Unknown 3: Where "unusable" is computed and how it propagates
24
+
25
+ **Decision**: **Reuse the existing `staleSources` state already implemented in `AnalysisShell.tsx:80,117-138`** (added for feature 002's stale-sources banner) rather than computing a new/parallel derivation. `staleSources` already cross-references `activeAnalysis.data_bind` against live `getDocuments`/`getDatabaseClients` results and is recomputed in a `useEffect` keyed on `activeAnalysis`. Derive `isActiveAnalysisUnusable = staleSources.length > 0` from it. Thread that boolean as a `disabled`/`unusable` prop into `ChatInput`, `HelpSkillButton` (both already accept `disabled`), a new `disabled` prop added to `SuggestedQuestionsBar`, and new disable conditions added to `ReportSidebar`'s generate-report button, `DataBindSelector`'s update action, and — per the second clarification — `AnalysisHeader.tsx`'s existing "Update binding" button and "N sources" trigger (both of which currently open the same `DataBindSelector`-backed editor used to fix `staleSources`).
26
+
27
+ **Rationale**: `AnalysisShell.tsx` already computes exactly the value this feature needs — an initial pass at this research (before source inspection) proposed a new computation, but that would have duplicated `staleSources` and diverged from it over time (see analysis finding A1). Per the first clarification answer, the resulting flag must remain standalone from `Analysis.status` — reusing `status` would corrupt the existing `listAnalyses({ status: "active" })` sidebar filter (`AnalysisShell.tsx:101`), silently hiding "unusable" analyses from the sidebar list, which directly contradicts FR-012 (chat history/reports must remain viewable/reachable). Per the second clarification answer, the pre-existing "Update binding" recovery control must **also** be disabled once `isActiveAnalysisUnusable` is true, even though that control already exists and today lets users fix exactly this condition — the feature intentionally removes that recovery path rather than special-casing it, so an unusable analysis has no in-app way back to usable (FR-009).
28
+
29
+ **Alternatives considered**:
30
+ - Real-time/live re-evaluation while the analysis screen is open (e.g., polling or a websocket) to catch a source being deleted in another tab while the user is mid-session: rejected as out of scope per the plan's Constraints — no push mechanism exists in this codebase, and the spec's Edge Cases only require "fail gracefully" on stale-state actions, not live UI updates.
31
+ - Storing the unusable flag as a new persisted field on `Analysis` returned by the backend: rejected — no backend contract change is needed since the flag is fully derivable client-side from data already fetched, keeping this a frontend-only change per the Summary.
32
+ - Keeping the "Update binding" control enabled as a recovery escape hatch while disabling everything else: considered and rejected per clarification — the user explicitly chose "disable it too" over preserving recovery.
specs/003-knowledge-delete-guardrails/spec.md ADDED
@@ -0,0 +1,124 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Feature Specification: Knowledge Source Deletion Guardrails
2
+
3
+ **Feature Branch**: `003-knowledge-delete-guardrails`
4
+
5
+ **Created**: 2026-07-24
6
+
7
+ **Status**: Draft
8
+
9
+ **Input**: User description: "ada beberapa behavior frontend yang perlu diperbaiki, 1. di menu knowledge, ketika user hendak menghapus salah satu atau semua sources (baik database / document) maka perlu ada pop up konfirmasi lagi ke user, apakah user benar-benar setuju atau tidak. Pada pop up tersebut ada informasi yang menjelaskan impactnya jika source tersebut dihapus (contoh: dokumen A atau database X digunakan pada Analisis XXX, analisis XXX tidak akan bisa digunakan lagi). Kemudian perlu ada rules untuk nama analysis yang di create tidak boleh ada yang sama persis. 2. Apabila user yakin untuk menghapus knowledge source (yang sudah di bind ke analysis), maka user tidak akan bisa lagi mengirim pesan ke analysis ini, tidak bisa update data yang ter-bind ke analysis ini, kemudian ada flagging analysis ini tidak bisa digunakan lagi meskipun masih bisa melihat chat history atau melihat report yang pernah ter-generate sebelumnya. Tombol tulis chat, kirim chat, generate report, help, recommendation question jadi disable karena ada knowledge yang terhapus."
10
+
11
+ ## Clarifications
12
+
13
+ ### Session 2026-07-24
14
+
15
+ - Q: Should the "unusable" state reuse the existing analysis status concept, or be a separate independent flag? → A: "Unusable" is a separate, independent flag computed purely from comparing bound sources vs. existing sources, unrelated to any status field
16
+ - Q: The codebase already has a "stale sources" banner (`AnalysisHeader.tsx`) with an "Update binding" control that lets a user re-bind a replacement source. Should that control stay enabled for an unusable analysis so the user can recover it, or be disabled along with everything else? → A: Disabled along with everything else — an unusable analysis has no in-app recovery path; the flag is permanent for that analysis within this feature's scope.
17
+
18
+ ## User Scenarios & Testing *(mandatory)*
19
+
20
+ ### User Story 1 - Confirm knowledge source deletion with impact preview (Priority: P1)
21
+
22
+ A user managing Knowledge sources (documents or database connections) clicks delete on a single source, or clicks "clear all" to delete every source. Before anything is actually removed, the system shows a confirmation dialog that lists the impact of the deletion — specifically, which analyses currently use that source and a warning that those analyses will stop working. The user must explicitly confirm before the deletion proceeds; they can also cancel and keep the source.
23
+
24
+ **Why this priority**: Deleting a knowledge source is destructive and currently happens with no confirmation at all (or only a generic browser confirm for "clear all"), risking accidental data loss and silently broken analyses. This is the foundational safeguard the rest of the feature depends on.
25
+
26
+ **Independent Test**: Can be fully tested by attempting to delete a document or database source (used by at least one analysis) and verifying a confirmation dialog appears listing the affected analyses by name, and that choosing "cancel" leaves the source and all analyses untouched, while choosing "confirm" proceeds with deletion.
27
+
28
+ **Acceptance Scenarios**:
29
+
30
+ 1. **Given** a document source that is bound to one or more analyses, **When** the user clicks delete on that source, **Then** a confirmation dialog appears listing each affected analysis by name and stating those analyses will no longer be usable after deletion.
31
+ 2. **Given** a database source that is not bound to any analysis, **When** the user clicks delete on that source, **Then** a confirmation dialog appears indicating no analyses will be affected, and the user can still confirm or cancel.
32
+ 3. **Given** the confirmation dialog is open, **When** the user clicks "cancel", **Then** the source is not deleted and the dialog closes.
33
+ 4. **Given** the confirmation dialog is open, **When** the user clicks "confirm", **Then** the source is deleted and any bound analyses are flagged as unusable (see User Story 3).
34
+ 5. **Given** the user clicks "clear all sources", **When** the confirmation dialog appears, **Then** it lists the combined set of all analyses that would be affected across all sources being cleared.
35
+
36
+ ---
37
+
38
+ ### User Story 2 - Prevent duplicate analysis names (Priority: P2)
39
+
40
+ A user creating a new analysis enters a name that is identical to the name of an existing analysis. The system blocks creation and informs the user that the name is already in use, prompting them to choose a different name.
41
+
42
+ **Why this priority**: Duplicate analysis names create ambiguity when browsing, searching, or referencing analyses (including in the impact-preview list from User Story 1), so this is important but independent of the deletion-confirmation flow and can ship separately.
43
+
44
+ **Independent Test**: Can be fully tested by creating an analysis with a name, then attempting to create a second analysis using the exact same name, and verifying the second attempt is blocked with a clear message while a differently-named analysis creates successfully.
45
+
46
+ **Acceptance Scenarios**:
47
+
48
+ 1. **Given** an existing analysis named "Sales Q1", **When** a user attempts to create a new analysis also named "Sales Q1" (exact match), **Then** the system prevents submission and shows a message that the name is already in use.
49
+ 2. **Given** an existing analysis named "Sales Q1", **When** a user creates a new analysis named "sales q1 " (different casing/whitespace) or "Sales Q2", **Then** the near-duplicate is blocked per the matching rule in Assumptions, and the non-matching name is allowed to proceed.
50
+ 3. **Given** the name-in-use message is shown, **When** the user edits the name to something unique, **Then** the creation action becomes available again.
51
+
52
+ ---
53
+
54
+ ### User Story 3 - Disable a usable analysis after its knowledge source is deleted (Priority: P1)
55
+
56
+ A user opens an analysis whose bound knowledge source(s) have been deleted (via User Story 1). The analysis is visibly flagged as no longer usable. The user can still browse the full chat history and view previously generated reports, but every action that would create new activity — writing/sending a chat message, using the help/skill button, using suggested/recommended questions, generating a new report, or updating the analysis's bound data — is disabled.
57
+
58
+ **Why this priority**: Without this, a deleted source leaves an analysis in a broken state where users can still attempt actions that will fail unexpectedly (e.g., sending a chat message with no backing data), producing confusing errors instead of a clear, upfront explanation. This is the direct consequence of User Story 1 and is equally critical.
59
+
60
+ **Independent Test**: Can be fully tested by deleting a knowledge source bound to an existing analysis, then opening that analysis and verifying: a visible "unusable" flag/banner is shown, chat input/send/help/suggested-questions/generate-report/update-data controls are disabled, while chat history and prior reports remain viewable.
61
+
62
+ **Acceptance Scenarios**:
63
+
64
+ 1. **Given** an analysis bound only to a knowledge source that has since been deleted, **When** the user opens that analysis, **Then** a clear indicator (e.g., banner or label) explains the analysis is unusable because a bound knowledge source was deleted.
65
+ 2. **Given** an analysis flagged as unusable, **When** the user views the chat panel, **Then** the chat input field, send button, help button, and suggested/recommended question buttons are all disabled.
66
+ 3. **Given** an analysis flagged as unusable, **When** the user looks for a way to generate a new report, **Then** the generate-report action is disabled.
67
+ 4. **Given** an analysis flagged as unusable, **When** the user looks for a way to change its bound data sources, **Then** the update/change-bound-data action is disabled.
68
+ 5. **Given** an analysis flagged as unusable, **When** the user scrolls through existing chat messages or opens a previously generated report, **Then** the content is fully viewable exactly as before.
69
+ 6. **Given** an analysis is bound to multiple knowledge sources and only one is deleted, **When** the user opens the analysis, **Then** the analysis is still flagged unusable (any missing bound source is sufficient to disable it) per the rule in Assumptions.
70
+
71
+ ---
72
+
73
+ ### Edge Cases
74
+
75
+ - What happens if a user has the deletion confirmation dialog open and, in another tab/session, the source is already deleted or an affected analysis is deleted first? The confirmation action should fail gracefully with a message rather than silently succeeding or erroring uninformatively.
76
+ - What happens when a knowledge source that was deleted is later replaced or a new source with a similar name is added? The previously flagged analysis remains unusable — flagging is tied to the specific deleted source/binding, not resolved by adding an unrelated new source, unless the user explicitly re-binds the analysis to a valid source (out of scope for this feature; see Assumptions).
77
+ - How does the impact list behave when a source is bound to a very large number of analyses? The dialog should present the list in a readable, scannable way (e.g., scrollable list) rather than breaking the layout.
78
+ - What happens if the user attempts analysis creation with a name that only differs by leading/trailing whitespace or case from an existing name? Covered by the near-duplicate rule in Assumptions.
79
+ - What happens to an analysis that has zero bound sources at all (never bound, not a deletion scenario)? This feature only concerns analyses that had a bound source removed; analyses with no bound source retain their current existing behavior.
80
+
81
+ ## Requirements *(mandatory)*
82
+
83
+ ### Functional Requirements
84
+
85
+ - **FR-001**: System MUST show a confirmation dialog before deleting any single knowledge source (document or database), and before executing a "clear all sources" action.
86
+ - **FR-002**: The confirmation dialog MUST display, for the source(s) being deleted, the list of analyses currently bound to it/them by name, so the user understands which analyses will stop working.
87
+ - **FR-003**: The confirmation dialog MUST clearly state that listed analyses will no longer be usable if the deletion proceeds.
88
+ - **FR-004**: If a source being deleted is not bound to any analysis, the confirmation dialog MUST still appear but indicate that no analyses will be affected.
89
+ - **FR-005**: The user MUST be able to cancel the confirmation dialog, in which case no deletion occurs and no analysis state changes.
90
+ - **FR-006**: The user MUST be able to confirm the dialog, in which case the source is deleted and any bound analyses are flagged as unusable per FR-009.
91
+ - **FR-007**: System MUST prevent creating a new analysis whose name is an exact (case/whitespace-insensitive) duplicate of an existing analysis's name, and MUST inform the user of the conflict.
92
+ - **FR-008**: System MUST apply the duplicate-name check consistently regardless of which knowledge sources are bound to the new analysis.
93
+ - **FR-009**: When a knowledge source is deleted, System MUST flag every analysis bound to that source as unusable. This flag is permanent for that analysis within this feature's scope: no in-app action can restore usability, because the update-bound-data action is itself disabled once an analysis is unusable (see FR-011/FR-014), including any pre-existing "update binding" control (e.g., the stale-sources banner's recovery button). Restoring usability would require a separate, out-of-scope capability.
94
+ - **FR-010**: An analysis flagged as unusable MUST visibly communicate to the user why it is unusable (i.e., that a bound knowledge source was deleted). This indicator MAY reuse/extend an existing "sources are no longer available" banner if one already exists in the UI, rather than requiring a separate new banner.
95
+ - **FR-011**: For an analysis flagged as unusable, System MUST disable: the chat input field, the send-message action, the help/skill action, the suggested/recommended question actions, the generate-report action, and the update/change-bound-data action — including any pre-existing control whose purpose is to let the user fix/re-bind the analysis's data sources (e.g., an existing "update binding" recovery button).
96
+ - **FR-012**: For an analysis flagged as unusable, System MUST continue to allow full read access to existing chat history and previously generated reports.
97
+ - **FR-013**: System MUST prevent a chat message from being sent to an unusable analysis even if attempted through means other than the disabled button (e.g., keyboard submit).
98
+ - **FR-014**: System MUST prevent updating the bound data of an unusable analysis even if attempted through means other than the disabled control.
99
+
100
+ ### Key Entities *(include if feature involves data)*
101
+
102
+ - **Knowledge Source**: A document or database connection available for use by analyses; has an identifier, a type (document/database), a name, and zero or more analyses bound to it.
103
+ - **Analysis**: A chat-based analysis bound to one or more knowledge sources; has a name (must be unique among analyses), chat history, generated reports, and a usability state (usable / unusable). This usability state is a standalone flag derived solely from comparing bound source references against currently existing knowledge sources — it is independent of and does not overload any other existing analysis status/lifecycle field.
104
+ - **Deletion Impact Summary**: The set of analyses affected by deleting one or more knowledge sources, computed and shown at confirmation time.
105
+
106
+ ## Success Criteria *(mandatory)*
107
+
108
+ ### Measurable Outcomes
109
+
110
+ - **SC-001**: 100% of knowledge source deletions (single or "clear all") require explicit user confirmation before any data is removed.
111
+ - **SC-002**: Users can identify, before confirming a deletion, which specific analyses will be affected without leaving the confirmation dialog or performing any additional clicks/scrolling beyond what's needed to read a short list.
112
+ - **SC-003**: Zero analyses can be created with a name that exactly (case/whitespace-insensitive) duplicates an existing analysis name.
113
+ - **SC-004**: 100% of analyses whose bound knowledge source was deleted show a visible unusable indicator and have chat, help, suggested questions, generate report, and data-update actions disabled on next view.
114
+ - **SC-005**: Users can still access 100% of previously generated chat history and reports for an unusable analysis, with zero loss of read access.
115
+
116
+ ## Assumptions
117
+
118
+ - Near-duplicate analysis names (differing only by case or leading/trailing whitespace) are treated as duplicates and blocked; matching is case-insensitive and ignores leading/trailing whitespace. Names that differ by internal spacing, punctuation, or any other characters are treated as distinct.
119
+ - "Unusable" is a client-visible state derived from comparing an analysis's bound source references against the current set of existing knowledge sources (i.e., any bound source missing marks the analysis unusable); a single missing source is enough to disable the whole analysis, even if other bound sources are still intact.
120
+ - Re-binding an unusable analysis to a new/replacement knowledge source to restore usability is explicitly disabled by this feature (per clarification), even where a pre-existing UI control for updating bound data already exists; building an alternate/separate recovery capability is out of scope for this feature.
121
+ - The impact-preview list in the confirmation dialog shows analysis names only (not full analysis details); clicking into individual analyses from the dialog is not required.
122
+ - "Clear all sources" refers to deleting all knowledge sources of the type(s) currently in scope of that action (e.g., all documents, or all sources), consistent with existing clear-all behavior in the Knowledge menu.
123
+ - Existing analyses that currently have no bound knowledge source at all are unaffected by this feature and keep their current behavior.
124
+ - Deletion of a knowledge source is still permitted even when it would make analyses unusable; the confirmation dialog is a warning, not a hard block — the user retains final say once informed of the impact.
specs/003-knowledge-delete-guardrails/tasks.md ADDED
@@ -0,0 +1,182 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+
3
+ description: "Task list for Knowledge Source Deletion Guardrails"
4
+
5
+ ---
6
+
7
+ # Tasks: Knowledge Source Deletion Guardrails
8
+
9
+ **Input**: Design documents from `/specs/003-knowledge-delete-guardrails/`
10
+
11
+ **Prerequisites**: [plan.md](./plan.md), [spec.md](./spec.md), [research.md](./research.md), [data-model.md](./data-model.md), [contracts/components.md](./contracts/components.md), [quickstart.md](./quickstart.md)
12
+
13
+ **Tests**: Not requested in the feature specification — no automated test framework is configured in this project (see plan.md Technical Context). Verification is manual, per `quickstart.md`.
14
+
15
+ **Organization**: Tasks are grouped by user story (US1–US3, matching spec.md priorities P1/P2/P1) to enable independent implementation and testing of each story.
16
+
17
+ ## Format: `[ID] [P?] [Story] Description`
18
+
19
+ - **[P]**: Can run in parallel (different files, no dependencies)
20
+ - **[Story]**: Which user story this task belongs to
21
+ - Tasks touching `src/app/components/analysis/AnalysisShell.tsx` are never marked `[P]` relative to each other — they share one file and must be applied sequentially in ID order.
22
+
23
+ ## Path Conventions
24
+
25
+ Single frontend project. All paths are relative to repo root.
26
+
27
+ ---
28
+
29
+ ## Phase 1: Setup
30
+
31
+ **Purpose**: Add the one shared, reusable primitive this feature needs before any user story can use it.
32
+
33
+ - [X] T001 [P] Create `src/app/components/ui/alert-dialog.tsx`, a shadcn-style wrapper around the already-installed `@radix-ui/react-alert-dialog` dependency, exporting `AlertDialog`, `AlertDialogTrigger`, `AlertDialogContent`, `AlertDialogHeader`, `AlertDialogTitle`, `AlertDialogDescription`, `AlertDialogFooter`, `AlertDialogAction`, `AlertDialogCancel` (per contracts/components.md), styled consistently with existing `src/app/components/ui/*` primitives (e.g. `resizable.tsx`). No new dependency to install — `@radix-ui/react-alert-dialog` is already in `package.json`. — **Already existed in the codebase** with all required exports; no work needed, confirmed by reading the file.
34
+
35
+ **Checkpoint**: `AlertDialog` primitive available for import; ready for US1.
36
+
37
+ ---
38
+
39
+ ## Phase 2: Foundational (Blocking Prerequisites)
40
+
41
+ **Purpose**: Core infrastructure that MUST be complete before ANY user story can be implemented.
42
+
43
+ **N/A for this feature** — US1 (Knowledge deletion confirmation), US2 (duplicate analysis name), and US3 (disable unusable analysis) touch disjoint sets of components (`KnowledgeManagement.tsx` vs. `NewAnalysisDialog.tsx` vs. `AnalysisShell.tsx`/`ReportSidebar.tsx`/`DataBindSelector.tsx`/`SuggestedQuestionsBar.tsx`) and share no blocking data model, auth, or routing scaffolding. Each story is independently implementable once Phase 1's `AlertDialog` primitive exists (only needed by US1).
44
+
45
+ **Checkpoint**: Proceed directly to user story implementation.
46
+
47
+ ---
48
+
49
+ ## Phase 3: User Story 1 - Confirm knowledge source deletion with impact preview (Priority: P1) 🎯 MVP
50
+
51
+ **Goal**: Deleting a document or database source (single, or "clear all documents") always shows a confirmation dialog listing which analyses will be affected before anything is actually removed.
52
+
53
+ **Independent Test**: Delete a document/database source bound to at least one analysis, verify the dialog lists the affected analysis by name and that Cancel leaves everything untouched while Confirm proceeds with deletion (see quickstart.md § User Story 1).
54
+
55
+ ### Implementation for User Story 1
56
+
57
+ - [X] T002 [US1] In `src/app/components/KnowledgeManagement.tsx`, import `listAnalyses` from `../../services/orchestrationApi` and add internal state: `deleteIntent: DeleteIntent | null`, `impact: { id: string; analysis_title: string }[]`, `loadingImpact: boolean` (per contracts/components.md `DeleteIntent` union: `document`, `database`, `clear-all-documents`).
58
+ - [X] T003 [US1] In `src/app/components/KnowledgeManagement.tsx`, add a helper `computeImpact(sourceIds: string[])` that calls `listAnalyses()` and filters/dedupes analyses whose `data_bind` contains any of `sourceIds` (per data-model.md `DeletionImpactSummary`), setting `loadingImpact`/`impact` around the call.
59
+ - [X] T004 [US1] In `src/app/components/KnowledgeManagement.tsx`, change the document delete button's `onClick` (currently calling `handleDeleteDocument(doc.id)` directly, ~line 687) to instead call `setDeleteIntent({ kind: "document", docId: doc.id, docName: doc.filename })` and `computeImpact([doc.id])`. Depends on T002, T003.
60
+ - [X] T005 [P] [US1] In `src/app/components/KnowledgeManagement.tsx`, change the database client delete button's `onClick` (currently calling `handleDeleteClient(client.id)` directly, ~line 617) to instead call `setDeleteIntent({ kind: "database", clientId: client.id, clientName: client.name })` and `computeImpact([client.id])`. Depends on T002, T003.
61
+ - [X] T006 [P] [US1] In `src/app/components/KnowledgeManagement.tsx`, replace the `window.confirm(...)` call in `deleteAllDocuments` (~line 266) with `setDeleteIntent({ kind: "clear-all-documents" })` and `computeImpact(documents.map(d => d.id))`. Depends on T002, T003.
62
+ - [X] T007 [US1] In `src/app/components/KnowledgeManagement.tsx`, render an `AlertDialog` (imported from `./ui/alert-dialog`, added in T001) open whenever `deleteIntent !== null`, with: a title/description branching on `deleteIntent.kind` (per FR-002/FR-003), a scrollable list of `impact` analysis titles when `impact.length > 0` (per Edge Cases: large impact lists must stay scannable) or a "no analyses will be affected" message when `impact.length === 0` (FR-004), an `AlertDialogCancel` that resets `deleteIntent`/`impact` with no API call (FR-005), and an `AlertDialogAction` that performs the actual delete for the given `deleteIntent.kind` — calling existing `handleDeleteDocument`/`handleDeleteClient` for single deletes, or looping `deleteDocument` calls (the existing body of `deleteAllDocuments` minus its `window.confirm`) for `clear-all-documents` — then resets `deleteIntent`/`impact` (FR-006). Depends on T004, T005, T006.
63
+ - [ ] T008 [US1] Manually verify per quickstart.md § User Story 1 — **NOT YET DONE, requires a human/browser pass**; `npm run build` succeeds with no compile/runtime-startup errors (confirms code correctness), but actual UI/UX verification needs manual browser testing: confirm dialog appears for a bound and an unbound source, for clear-all with mixed bound/unbound docs, and that Cancel vs. Confirm behave per FR-005/FR-006. Depends on T007.
64
+
65
+ **Checkpoint**: Knowledge deletion confirmation with impact preview fully functional and independently testable per quickstart.md § User Story 1.
66
+
67
+ ---
68
+
69
+ ## Phase 4: User Story 2 - Prevent duplicate analysis names (Priority: P2)
70
+
71
+ **Goal**: Creating a new analysis with a name that exactly (case/whitespace-insensitively) matches an existing analysis's name is blocked with a clear message.
72
+
73
+ **Independent Test**: Create an analysis, then attempt to create a second one with the identical (or same-but-cased/whitespace-padded) name, and verify it's blocked while a distinct name succeeds (see quickstart.md § User Story 2).
74
+
75
+ ### Implementation for User Story 2
76
+
77
+ - [X] T009 [P] [US2] In `src/app/components/analysis/NewAnalysisDialog.tsx`, add a new required prop `existingAnalyses: Analysis[]` to `NewAnalysisDialogProps` (per contracts/components.md) and a helper `isDuplicateName(candidate: string, existing: Analysis[])` comparing `analysis_title.trim().toLowerCase()` (per data-model.md).
78
+ - [X] T010 [US2] In `src/app/components/analysis/NewAnalysisDialog.tsx`, add `isDuplicateName(title, existingAnalyses)` as an additional `canSubmit` condition, and render an inline error near the title field (same visual pattern as the existing `error` block) when the current title is a duplicate, without blocking further typing. Depends on T009.
79
+ - [X] T011 [US2] In `src/app/components/analysis/AnalysisShell.tsx`, pass `existingAnalyses={analyses}` (the already-loaded sidebar analyses state) to `<NewAnalysisDialog />` (~line 554).
80
+ - [ ] T012 [US2] Manually verify per quickstart.md § User Story 2 — **NOT YET DONE, requires a human/browser pass**: exact match blocked, case/whitespace variant blocked, distinct name succeeds, editing to a unique name re-enables submission, and the check behaves the same regardless of which knowledge sources are bound to the new analysis (FR-008). Depends on T010, T011.
81
+
82
+ **Checkpoint**: Duplicate analysis name guard fully functional and independently testable per quickstart.md § User Story 2.
83
+
84
+ ---
85
+
86
+ ## Phase 5: User Story 3 - Disable a usable analysis after its knowledge source is deleted (Priority: P1)
87
+
88
+ **Goal**: An analysis whose bound knowledge source no longer exists is visibly flagged unusable and has all activity-generating actions (chat, help, suggested questions, generate report, update bound data) disabled, while chat history and reports remain fully viewable.
89
+
90
+ **Independent Test**: Delete a knowledge source bound to an existing analysis (via the app's existing delete action), open that analysis, and verify the unusable banner appears, all listed actions are disabled, and history/reports remain viewable (see quickstart.md § User Story 3).
91
+
92
+ ### Implementation for User Story 3
93
+
94
+ - [X] T013 [P] [US3] In `src/app/components/analysis/SuggestedQuestionsBar.tsx`, add an optional `disabled?: boolean` prop; when `true`, render every question button with the `disabled` attribute and make the existing `onSelect` handler a no-op (per contracts/components.md).
95
+ - [X] T014 [P] [US3] In `src/app/components/analysis/ReportSidebar.tsx`, add an optional `disabled?: boolean` prop; when `true`, disable the generate-report button (`handleGenerate`'s trigger) while leaving the report list/version selector/report viewing fully interactive (per contracts/components.md, FR-011/FR-012).
96
+ - [X] T015 [P] [US3] In `src/app/components/analysis/AnalysisHeader.tsx`, add an optional `unusable?: boolean` prop (per contracts/components.md). When `true`: disable the "N sources" button (`openSourceEditor`, ~line 40-43) so the editor cannot be opened; disable the stale-sources banner's "Update binding" button (~line 75-85, prevent `setEditingSources(true)`); and force the embedded `DataBindSelector` (~line 91) and "Save sources" button (~line 97-105) to `disabled` if `editingSources` is already `true`. This intentionally disables the same control that today lets a user fix `staleSources` — per clarification, an unusable analysis has no in-app recovery path (FR-009/FR-011/FR-014).
97
+ - [X] T016 [US3] In `src/app/components/analysis/AnalysisShell.tsx`, add `const isActiveAnalysisUnusable = staleSources.length > 0;`, reusing the **already-existing** `staleSources` state (`AnalysisShell.tsx:80`, computed by the existing `useEffect` at `AnalysisShell.tsx:117-138` which already fetches `getDocuments`/`getDatabaseClients` and cross-references `activeAnalysis.data_bind`) — do **not** add a new/parallel fetch or derivation (per research.md Unknown 3 / data-model.md `isActiveAnalysisUnusable`).
98
+ - [X] T017 [US3] In `src/app/components/analysis/AnalysisHeader.tsx`, extend the existing stale-sources banner copy (~line 69-72, "Some bound sources are no longer available...") to also state the analysis can no longer be used (FR-010) — reuse this banner rather than adding a new one elsewhere. In `src/app/components/analysis/AnalysisShell.tsx`, pass `unusable={isActiveAnalysisUnusable}` into `<AnalysisHeader />` (~line 372). Depends on T015, T016.
99
+ - [X] T018 [US3] In `src/app/components/analysis/AnalysisShell.tsx`, pass `disabled={isActiveAnalysisUnusable}` into `ChatInput` (which already forwards `disabled` to its Send button, textarea, and `HelpSkillButton` per existing code), `SuggestedQuestionsBar` (from T013), and `ReportSidebar` (from T014). Depends on T013, T014, T016.
100
+ - [X] T019 [US3] In `src/app/components/analysis/ChatInput.tsx`, confirm the existing `onKeyDown` Enter-to-submit path already respects `disabled` via `canSend` (it does — `canSend = message.trim().length > 0 && !disabled && !streaming`); no code change expected, verify only (per FR-013).
101
+ - [ ] T020 [US3] Manually verify per quickstart.md § User Story 3 — **NOT YET DONE, requires a human/browser pass**: unusable banner appears (reusing the stale-sources banner); chat input/send/help/suggested-questions/generate-report are all disabled; the "N sources"/"Update binding" controls and embedded `DataBindSelector` are all disabled with no way to open or submit the source editor (FR-014); chat history and prior reports remain fully viewable; an analysis with multiple bound sources where only one was deleted is still flagged unusable; an analysis with no bound sources is unaffected; the sidebar (`listAnalyses({ status: "active" })`) still lists the unusable analysis, confirming `status` was not repurposed. Depends on T017, T018, T019.
102
+
103
+ **Checkpoint**: Unusable-analysis flagging and action-disabling fully functional and independently testable per quickstart.md § User Story 3.
104
+
105
+ ---
106
+
107
+ ## Phase 6: Polish & Cross-Cutting Concerns
108
+
109
+ **Purpose**: Final validation across all three stories together.
110
+
111
+ - [ ] T021 Run the full manual verification checklist in `quickstart.md` end-to-end (US1 → US3) against `npm run dev` (or `pnpm dev`), confirming no regressions between stories (e.g., an analysis flagged unusable by US3 still shows correctly in US1's impact-preview list if further sources bound to it are deleted).
112
+ - [ ] T022 [P] Spot-check the mobile drawer/bottom-sheet variants of `KnowledgeManagement.tsx` (`variant="modal"` vs. `"page"`) and `AnalysisShell.tsx`'s mobile report panel still render the new `AlertDialog` and unusable banner correctly at mobile viewport widths.
113
+
114
+ ---
115
+
116
+ ## Dependencies & Execution Order
117
+
118
+ ### Phase Dependencies
119
+
120
+ - **Setup (Phase 1)**: No dependencies — can start immediately. Only blocks US1 (T004–T007 need the `AlertDialog` primitive from T001).
121
+ - **Foundational (Phase 2)**: N/A — no blocking work.
122
+ - **User Stories (Phase 3–5)**: US1 depends on Phase 1 (T001). US2 and US3 have no dependency on Phase 1 or on each other and can start immediately in parallel with US1.
123
+ - **Polish (Phase 6)**: Depends on all three user stories being complete.
124
+
125
+ ### User Story Dependencies
126
+
127
+ - **US1 (P1)**: Depends only on Setup (T001, for `AlertDialog`). Touches `KnowledgeManagement.tsx` only.
128
+ - **US2 (P2)**: Fully independent — touches `NewAnalysisDialog.tsx` and one prop-passing line in `AnalysisShell.tsx` (T011).
129
+ - **US3 (P1)**: Fully independent of US1/US2 in intent (it only requires that *some* deletion has happened, which the app can already do today even before US1 ships) — touches `SuggestedQuestionsBar.tsx`, `ReportSidebar.tsx`, `AnalysisHeader.tsx`, and `AnalysisShell.tsx` (T016, T017, T018, sequential same-file edits; also share `AnalysisShell.tsx` with US2's T011, so apply T011 before T016–T018 if working in the same branch to avoid merge conflicts).
130
+
131
+ ### Parallel Opportunities
132
+
133
+ - T005 and T006 (`KnowledgeManagement.tsx` database/clear-all delete triggers) can run in parallel with T004 (document delete trigger) — same file, but non-overlapping code regions; sequence by ID if working solo.
134
+ - T009 (`NewAnalysisDialog.tsx`) can run any time, fully parallel with all of US1 and US3.
135
+ - T013, T014, T015 (`SuggestedQuestionsBar.tsx`, `ReportSidebar.tsx`, `AnalysisHeader.tsx`) can all run in parallel with each other and with US1/US2 — three different files, no shared state.
136
+ - T022 can run in parallel with T021.
137
+
138
+ ---
139
+
140
+ ## Parallel Example: Cross-story parallel batch (once Setup/T001 is done)
141
+
142
+ ```bash
143
+ # These touch entirely different files and can be worked on simultaneously:
144
+ Task: "Add existingAnalyses prop + duplicate-name guard to NewAnalysisDialog.tsx (T009, T010)"
145
+ Task: "Add disabled prop to SuggestedQuestionsBar.tsx (T013)"
146
+ Task: "Add disabled prop to ReportSidebar.tsx's generate-report button (T014)"
147
+ Task: "Add unusable prop to AnalysisHeader.tsx, disabling its source-editor controls (T015)"
148
+ ```
149
+
150
+ ---
151
+
152
+ ## Implementation Strategy
153
+
154
+ ### MVP First (User Story 1 Only)
155
+
156
+ 1. Complete Phase 1: Setup (T001).
157
+ 2. Complete Phase 3: User Story 1 (deletion confirmation + impact preview).
158
+ 3. **STOP and VALIDATE**: Run quickstart.md § User Story 1 independently.
159
+ 4. Deploy/demo if ready — this alone closes the biggest accidental-data-loss risk.
160
+
161
+ ### Incremental Delivery
162
+
163
+ 1. Setup (T001) → done.
164
+ 2. US1 (Phase 3) → validate → ship (MVP).
165
+ 3. US3 (Phase 5) → validate → ship (closes the loop opened by US1: analyses whose sources were just deleted become properly unusable).
166
+ 4. US2 (Phase 4) → validate → ship.
167
+ 5. Polish (Phase 6) → final cross-story pass.
168
+
169
+ Each story adds value without breaking previous stories. US1 and US3 are naturally paired (US1 makes deletion safe to do; US3 makes the consequence of doing it visible and enforced), but both can also ship independently since the app can already delete sources today.
170
+
171
+ ---
172
+
173
+ ## Notes
174
+
175
+ - No automated tests exist in this project; all verification is manual via `quickstart.md`.
176
+ - [P] tasks = different files, no dependencies.
177
+ - [Story] label maps task to specific user story for traceability.
178
+ - `AnalysisShell.tsx` is shared across US2 (T011) and US3 (T016, T017, T018) — respect task ID ordering there even when working in parallel on other files.
179
+ - T015/T017's disabling of `AnalysisHeader.tsx`'s "Update binding" control is intentional (per clarification, an unusable analysis has no in-app recovery path) — do not "fix" this into an exception during implementation.
180
+ - `KnowledgeManagement.tsx` is shared across all of US1's tasks (T002–T007) — apply in ID order even if split across contributors.
181
+ - Commit after each task or logical group.
182
+ - Stop at any checkpoint to validate a story independently.
src/app/components/KnowledgeManagement.tsx CHANGED
@@ -27,6 +27,7 @@ import {
27
  ingestDatabaseClient,
28
  getDataCatalog,
29
  rebuildDataCatalog,
 
30
  type ApiDocument,
31
  type DocumentStatus,
32
  type DocTypeInfo,
@@ -35,6 +36,16 @@ import {
35
  type DatabaseClient,
36
  type DataCatalogSource,
37
  } from "../../services/orchestrationApi";
 
 
 
 
 
 
 
 
 
 
38
 
39
  interface KnowledgeManagementProps {
40
  open: boolean;
@@ -44,6 +55,16 @@ interface KnowledgeManagementProps {
44
 
45
  type View = "main" | "db-select" | "db-credentials" | "catalog";
46
 
 
 
 
 
 
 
 
 
 
 
47
  const LOGO_MAP: Record<string, string> = {
48
  postgres: "https://cdn.simpleicons.org/postgresql/336791",
49
  mysql: "https://cdn.simpleicons.org/mysql/4479A1",
@@ -85,6 +106,12 @@ export default function KnowledgeManagement({
85
  const [processing, setProcessing] = useState<string | null>(null);
86
  const [deleting, setDeleting] = useState<string | null>(null);
87
 
 
 
 
 
 
 
88
  // -- Navigation state --------------------------------------------------------
89
  const [view, setView] = useState<View>("main");
90
  const [selectedDbType, setSelectedDbType] = useState<DbType | null>(null);
@@ -263,8 +290,6 @@ export default function KnowledgeManagement({
263
  };
264
 
265
  const deleteAllDocuments = async () => {
266
- if (!window.confirm("Are you sure you want to delete all documents?"))
267
- return;
268
  const userId = getUserId();
269
  if (!userId) return;
270
  for (const doc of documents) {
@@ -277,6 +302,65 @@ export default function KnowledgeManagement({
277
  setDocuments([]);
278
  };
279
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
280
  // -- DB handlers -------------------------------------------------------------
281
 
282
  const handleDbConnect = async () => {
@@ -446,6 +530,7 @@ export default function KnowledgeManagement({
446
  // -- Render -------------------------------------------------------------------
447
 
448
  return (
 
449
  <div className={isPage ? "knowledge-surface h-full min-h-screen bg-slate-50" : "knowledge-surface fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4 backdrop-blur-sm"}>
450
  <div className={isPage ? "flex h-full min-h-screen w-full flex-col bg-white" : "flex max-h-[85vh] w-full max-w-md flex-col rounded-2xl bg-white shadow-xl"}>
451
 
@@ -614,7 +699,7 @@ export default function KnowledgeManagement({
614
  )}
615
  </div>
616
  <button
617
- onClick={() => handleDeleteClient(client.id)}
618
  disabled={deletingClient === client.id}
619
  className="flex h-8 w-8 items-center justify-center rounded-lg text-slate-300 opacity-100 transition hover:bg-red-50 hover:text-red-500 disabled:opacity-30 sm:h-auto sm:w-auto sm:opacity-0 sm:group-hover:opacity-100"
620
  title="Delete connection"
@@ -640,7 +725,7 @@ export default function KnowledgeManagement({
640
  </span>
641
  {documents.length > 0 && (
642
  <button
643
- onClick={deleteAllDocuments}
644
  className="text-xs text-slate-400 hover:text-red-500 flex items-center gap-1 transition"
645
  >
646
  <Trash2 className="w-3 h-3" />
@@ -684,7 +769,7 @@ export default function KnowledgeManagement({
684
  <div className="flex flex-shrink-0 items-center gap-1.5 sm:gap-2">
685
  {renderStatus(doc)}
686
  <button
687
- onClick={() => handleDeleteDocument(doc.id)}
688
  disabled={deleting === doc.id}
689
  className="flex h-8 w-8 items-center justify-center rounded-lg text-slate-300 opacity-100 transition hover:bg-red-50 hover:text-red-500 disabled:opacity-30 sm:h-auto sm:w-auto sm:opacity-0 sm:group-hover:opacity-100"
690
  title="Delete"
@@ -924,5 +1009,58 @@ export default function KnowledgeManagement({
924
 
925
  </div>
926
  </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
927
  );
928
  }
 
27
  ingestDatabaseClient,
28
  getDataCatalog,
29
  rebuildDataCatalog,
30
+ listAnalyses,
31
  type ApiDocument,
32
  type DocumentStatus,
33
  type DocTypeInfo,
 
36
  type DatabaseClient,
37
  type DataCatalogSource,
38
  } from "../../services/orchestrationApi";
39
+ import {
40
+ AlertDialog,
41
+ AlertDialogAction,
42
+ AlertDialogCancel,
43
+ AlertDialogContent,
44
+ AlertDialogDescription,
45
+ AlertDialogFooter,
46
+ AlertDialogHeader,
47
+ AlertDialogTitle,
48
+ } from "./ui/alert-dialog";
49
 
50
  interface KnowledgeManagementProps {
51
  open: boolean;
 
55
 
56
  type View = "main" | "db-select" | "db-credentials" | "catalog";
57
 
58
+ type DeleteIntent =
59
+ | { kind: "document"; docId: string; docName: string }
60
+ | { kind: "database"; clientId: string; clientName: string }
61
+ | { kind: "clear-all-documents" };
62
+
63
+ interface ImpactedAnalysis {
64
+ id: string;
65
+ analysis_title: string;
66
+ }
67
+
68
  const LOGO_MAP: Record<string, string> = {
69
  postgres: "https://cdn.simpleicons.org/postgresql/336791",
70
  mysql: "https://cdn.simpleicons.org/mysql/4479A1",
 
106
  const [processing, setProcessing] = useState<string | null>(null);
107
  const [deleting, setDeleting] = useState<string | null>(null);
108
 
109
+ // -- Deletion confirmation state ---------------------------------------------
110
+ const [deleteIntent, setDeleteIntent] = useState<DeleteIntent | null>(null);
111
+ const [impact, setImpact] = useState<ImpactedAnalysis[]>([]);
112
+ const [loadingImpact, setLoadingImpact] = useState(false);
113
+ const [confirming, setConfirming] = useState(false);
114
+
115
  // -- Navigation state --------------------------------------------------------
116
  const [view, setView] = useState<View>("main");
117
  const [selectedDbType, setSelectedDbType] = useState<DbType | null>(null);
 
290
  };
291
 
292
  const deleteAllDocuments = async () => {
 
 
293
  const userId = getUserId();
294
  if (!userId) return;
295
  for (const doc of documents) {
 
302
  setDocuments([]);
303
  };
304
 
305
+ // -- Deletion confirmation ----------------------------------------------------
306
+
307
+ const computeImpact = async (sourceIds: string[]) => {
308
+ setLoadingImpact(true);
309
+ try {
310
+ const result = await listAnalyses({ status: "active", limit: 200 });
311
+ const affected = result.analyses.filter((analysis) =>
312
+ analysis.data_bind.some((item) => sourceIds.includes(item.id))
313
+ );
314
+ const deduped = Array.from(new Map(affected.map((a) => [a.id, a])).values()).map(
315
+ (a) => ({ id: a.id, analysis_title: a.analysis_title })
316
+ );
317
+ setImpact(deduped);
318
+ } catch {
319
+ // non-blocking; if we can't compute impact, still allow the dialog to show with an empty list
320
+ setImpact([]);
321
+ } finally {
322
+ setLoadingImpact(false);
323
+ }
324
+ };
325
+
326
+ const requestDeleteDocument = (doc: ApiDocument) => {
327
+ setDeleteIntent({ kind: "document", docId: doc.id, docName: doc.filename });
328
+ void computeImpact([doc.id]);
329
+ };
330
+
331
+ const requestDeleteClient = (client: DatabaseClient) => {
332
+ setDeleteIntent({ kind: "database", clientId: client.id, clientName: client.name });
333
+ void computeImpact([client.id]);
334
+ };
335
+
336
+ const requestClearAllDocuments = () => {
337
+ setDeleteIntent({ kind: "clear-all-documents" });
338
+ void computeImpact(documents.map((d) => d.id));
339
+ };
340
+
341
+ const cancelDelete = () => {
342
+ setDeleteIntent(null);
343
+ setImpact([]);
344
+ };
345
+
346
+ const confirmDelete = async () => {
347
+ if (!deleteIntent) return;
348
+ setConfirming(true);
349
+ try {
350
+ if (deleteIntent.kind === "document") {
351
+ await handleDeleteDocument(deleteIntent.docId);
352
+ } else if (deleteIntent.kind === "database") {
353
+ await handleDeleteClient(deleteIntent.clientId);
354
+ } else {
355
+ await deleteAllDocuments();
356
+ }
357
+ } finally {
358
+ setConfirming(false);
359
+ setDeleteIntent(null);
360
+ setImpact([]);
361
+ }
362
+ };
363
+
364
  // -- DB handlers -------------------------------------------------------------
365
 
366
  const handleDbConnect = async () => {
 
530
  // -- Render -------------------------------------------------------------------
531
 
532
  return (
533
+ <>
534
  <div className={isPage ? "knowledge-surface h-full min-h-screen bg-slate-50" : "knowledge-surface fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4 backdrop-blur-sm"}>
535
  <div className={isPage ? "flex h-full min-h-screen w-full flex-col bg-white" : "flex max-h-[85vh] w-full max-w-md flex-col rounded-2xl bg-white shadow-xl"}>
536
 
 
699
  )}
700
  </div>
701
  <button
702
+ onClick={() => requestDeleteClient(client)}
703
  disabled={deletingClient === client.id}
704
  className="flex h-8 w-8 items-center justify-center rounded-lg text-slate-300 opacity-100 transition hover:bg-red-50 hover:text-red-500 disabled:opacity-30 sm:h-auto sm:w-auto sm:opacity-0 sm:group-hover:opacity-100"
705
  title="Delete connection"
 
725
  </span>
726
  {documents.length > 0 && (
727
  <button
728
+ onClick={requestClearAllDocuments}
729
  className="text-xs text-slate-400 hover:text-red-500 flex items-center gap-1 transition"
730
  >
731
  <Trash2 className="w-3 h-3" />
 
769
  <div className="flex flex-shrink-0 items-center gap-1.5 sm:gap-2">
770
  {renderStatus(doc)}
771
  <button
772
+ onClick={() => requestDeleteDocument(doc)}
773
  disabled={deleting === doc.id}
774
  className="flex h-8 w-8 items-center justify-center rounded-lg text-slate-300 opacity-100 transition hover:bg-red-50 hover:text-red-500 disabled:opacity-30 sm:h-auto sm:w-auto sm:opacity-0 sm:group-hover:opacity-100"
775
  title="Delete"
 
1009
 
1010
  </div>
1011
  </div>
1012
+
1013
+ <AlertDialog open={deleteIntent !== null} onOpenChange={(open) => { if (!open) cancelDelete(); }}>
1014
+ <AlertDialogContent>
1015
+ <AlertDialogHeader>
1016
+ <AlertDialogTitle>
1017
+ {deleteIntent?.kind === "document" && `Delete "${deleteIntent.docName}"?`}
1018
+ {deleteIntent?.kind === "database" && `Delete "${deleteIntent.clientName}"?`}
1019
+ {deleteIntent?.kind === "clear-all-documents" && "Delete all documents?"}
1020
+ </AlertDialogTitle>
1021
+ <AlertDialogDescription asChild>
1022
+ <div className="space-y-2 text-left">
1023
+ {loadingImpact ? (
1024
+ <div className="flex items-center gap-2 text-slate-500">
1025
+ <Loader2 className="h-3.5 w-3.5 animate-spin" />
1026
+ Checking which analyses use this source...
1027
+ </div>
1028
+ ) : impact.length === 0 ? (
1029
+ <p>No analyses will be affected by this deletion.</p>
1030
+ ) : (
1031
+ <>
1032
+ <p>
1033
+ The following analys{impact.length === 1 ? "is is" : "es are"} using this source and will no longer be usable after deletion:
1034
+ </p>
1035
+ <div className="max-h-40 overflow-y-auto rounded-md border border-slate-200 bg-slate-50">
1036
+ <ul className="divide-y divide-slate-200">
1037
+ {impact.map((analysis) => (
1038
+ <li key={analysis.id} className="px-3 py-2 text-sm text-slate-700 truncate">
1039
+ {analysis.analysis_title}
1040
+ </li>
1041
+ ))}
1042
+ </ul>
1043
+ </div>
1044
+ </>
1045
+ )}
1046
+ </div>
1047
+ </AlertDialogDescription>
1048
+ </AlertDialogHeader>
1049
+ <AlertDialogFooter>
1050
+ <AlertDialogCancel onClick={cancelDelete}>Cancel</AlertDialogCancel>
1051
+ <AlertDialogAction
1052
+ onClick={(e) => {
1053
+ e.preventDefault();
1054
+ void confirmDelete();
1055
+ }}
1056
+ disabled={confirming}
1057
+ className="bg-red-600 text-white hover:bg-red-700"
1058
+ >
1059
+ {confirming ? "Deleting..." : "Delete"}
1060
+ </AlertDialogAction>
1061
+ </AlertDialogFooter>
1062
+ </AlertDialogContent>
1063
+ </AlertDialog>
1064
+ </>
1065
  );
1066
  }
src/app/components/analysis/AnalysisHeader.tsx CHANGED
@@ -7,9 +7,10 @@ interface AnalysisHeaderProps {
7
  analysis: Analysis | null;
8
  staleSources?: DataBindItem[];
9
  onUpdateDataBind: (items: DataBindItem[]) => Promise<void>;
 
10
  }
11
 
12
- export function AnalysisHeader({ analysis, staleSources = [], onUpdateDataBind }: AnalysisHeaderProps) {
13
  const [editingSources, setEditingSources] = useState(false);
14
  const [draftBind, setDraftBind] = useState<DataBindItem[]>(analysis?.data_bind ?? []);
15
  const [saving, setSaving] = useState(false);
@@ -38,6 +39,7 @@ export function AnalysisHeader({ analysis, staleSources = [], onUpdateDataBind }
38
  };
39
 
40
  const openSourceEditor = () => {
 
41
  setDraftBind(analysis.data_bind);
42
  setEditingSources((open) => !open);
43
  };
@@ -51,9 +53,10 @@ export function AnalysisHeader({ analysis, staleSources = [], onUpdateDataBind }
51
  <div className="flex flex-shrink-0 items-center gap-2 text-xs text-slate-500">
52
  <button
53
  type="button"
54
- title="Update bound knowledge sources for this analysis"
55
  onClick={openSourceEditor}
56
- className="inline-flex items-center gap-1 rounded-md border border-slate-200 px-2 py-1 hover:bg-slate-50 hover:text-slate-800"
 
57
  >
58
  <Database className="h-3.5 w-3.5" />
59
  {analysis.data_bind.length} source{analysis.data_bind.length !== 1 ? "s" : ""}
@@ -66,27 +69,35 @@ export function AnalysisHeader({ analysis, staleSources = [], onUpdateDataBind }
66
  <div className="flex items-start gap-2">
67
  <AlertTriangle className="mt-0.5 h-4 w-4 flex-shrink-0" />
68
  <div>
69
- <p className="font-medium">Some bound sources are no longer available.</p>
 
 
 
 
70
  <p className="mt-1 text-xs leading-5 text-amber-800">
71
- Update sources before relying on this analysis: {staleSources.map((source) => source.name).join(", ")}.
 
 
72
  </p>
73
  </div>
74
  </div>
75
- <button
76
- type="button"
77
- title="Open source binding editor"
78
- onClick={() => {
79
- setDraftBind(analysis.data_bind);
80
- setEditingSources(true);
81
- }}
82
- className="rounded-md bg-amber-900 px-3 py-2 text-xs font-medium text-white hover:bg-amber-800"
83
- >
84
- Update binding
85
- </button>
 
 
86
  </div>
87
  )}
88
 
89
- {editingSources && (
90
  <div className="mt-4 rounded-lg border border-slate-200 bg-slate-50 p-3">
91
  <DataBindSelector value={draftBind} onChange={setDraftBind} disabled={saving} />
92
  {error && <p className="mt-2 text-xs text-red-600">{error}</p>}
 
7
  analysis: Analysis | null;
8
  staleSources?: DataBindItem[];
9
  onUpdateDataBind: (items: DataBindItem[]) => Promise<void>;
10
+ unusable?: boolean;
11
  }
12
 
13
+ export function AnalysisHeader({ analysis, staleSources = [], onUpdateDataBind, unusable = false }: AnalysisHeaderProps) {
14
  const [editingSources, setEditingSources] = useState(false);
15
  const [draftBind, setDraftBind] = useState<DataBindItem[]>(analysis?.data_bind ?? []);
16
  const [saving, setSaving] = useState(false);
 
39
  };
40
 
41
  const openSourceEditor = () => {
42
+ if (unusable) return;
43
  setDraftBind(analysis.data_bind);
44
  setEditingSources((open) => !open);
45
  };
 
53
  <div className="flex flex-shrink-0 items-center gap-2 text-xs text-slate-500">
54
  <button
55
  type="button"
56
+ title={unusable ? "Bound sources can no longer be updated for this analysis" : "Update bound knowledge sources for this analysis"}
57
  onClick={openSourceEditor}
58
+ disabled={unusable}
59
+ className="inline-flex items-center gap-1 rounded-md border border-slate-200 px-2 py-1 hover:bg-slate-50 hover:text-slate-800 disabled:cursor-not-allowed disabled:opacity-40 disabled:hover:bg-transparent disabled:hover:text-slate-500"
60
  >
61
  <Database className="h-3.5 w-3.5" />
62
  {analysis.data_bind.length} source{analysis.data_bind.length !== 1 ? "s" : ""}
 
69
  <div className="flex items-start gap-2">
70
  <AlertTriangle className="mt-0.5 h-4 w-4 flex-shrink-0" />
71
  <div>
72
+ <p className="font-medium">
73
+ {unusable
74
+ ? "Some bound sources are no longer available — this analysis can no longer be used."
75
+ : "Some bound sources are no longer available."}
76
+ </p>
77
  <p className="mt-1 text-xs leading-5 text-amber-800">
78
+ {unusable
79
+ ? `Missing sources: ${staleSources.map((source) => source.name).join(", ")}. Chat, help, suggested questions, report generation, and source updates are disabled for this analysis. You can still view its chat history and previously generated reports.`
80
+ : `Update sources before relying on this analysis: ${staleSources.map((source) => source.name).join(", ")}.`}
81
  </p>
82
  </div>
83
  </div>
84
+ {!unusable && (
85
+ <button
86
+ type="button"
87
+ title="Open source binding editor"
88
+ onClick={() => {
89
+ setDraftBind(analysis.data_bind);
90
+ setEditingSources(true);
91
+ }}
92
+ className="rounded-md bg-amber-900 px-3 py-2 text-xs font-medium text-white hover:bg-amber-800"
93
+ >
94
+ Update binding
95
+ </button>
96
+ )}
97
  </div>
98
  )}
99
 
100
+ {editingSources && !unusable && (
101
  <div className="mt-4 rounded-lg border border-slate-200 bg-slate-50 p-3">
102
  <DataBindSelector value={draftBind} onChange={setDraftBind} disabled={saving} />
103
  {error && <p className="mt-2 text-xs text-red-600">{error}</p>}
src/app/components/analysis/AnalysisShell.tsx CHANGED
@@ -367,9 +367,16 @@ export function AnalysisShell() {
367
  const activeMenuLabel =
368
  activeMenu === "analysis-agent" ? "Analysis Agent" : activeMenu === "knowledge" ? "Knowledge" : "Home";
369
 
 
 
370
  const chatPane = (
371
  <main className="flex h-full min-h-0 min-w-0 flex-col bg-slate-50">
372
- <AnalysisHeader analysis={activeAnalysis} staleSources={staleSources} onUpdateDataBind={handleUpdateDataBind} />
 
 
 
 
 
373
  <div className="min-h-0 flex-1 overflow-y-auto overflow-x-hidden overscroll-contain">
374
  {loadingMessages ? (
375
  <div className="flex h-full items-center justify-center text-sm text-slate-500">Loading messages</div>
@@ -384,9 +391,18 @@ export function AnalysisShell() {
384
  )}
385
  </div>
386
  {messages.length > 0 && (
387
- <SuggestedQuestionsBar questions={activeAnalysis?.business_questions ?? []} onSelect={handleSend} />
 
 
 
 
388
  )}
389
- <ChatInput disabled={!activeAnalysis} streaming={streamState !== "idle"} onSend={handleSend} onHelp={handleHelp} />
 
 
 
 
 
390
  </main>
391
  );
392
 
@@ -410,6 +426,7 @@ export function AnalysisShell() {
410
  onCollapse={() => setReportCollapsed(true)}
411
  fullscreen={reportFullscreen}
412
  onToggleFullscreen={() => setReportFullscreen((value) => !value)}
 
413
  />
414
  </div>
415
  );
@@ -521,7 +538,12 @@ export function AnalysisShell() {
521
  className="absolute inset-0 bg-slate-950/40"
522
  />
523
  <div className="absolute inset-x-0 bottom-0 max-h-[86vh] overflow-y-auto rounded-t-xl bg-white p-4 shadow-2xl">
524
- <ReportSidebar analysis={activeAnalysis} userId={session?.user_id} onCollapse={() => setMobileReportOpen(false)} />
 
 
 
 
 
525
  </div>
526
  </div>
527
  )}
@@ -551,7 +573,7 @@ export function AnalysisShell() {
551
  </div>
552
  </div>
553
 
554
- <NewAnalysisDialog open={newAnalysisOpen} onClose={() => setNewAnalysisOpen(false)} onCreated={handleCreated} />
555
  </div>
556
  );
557
  }
 
367
  const activeMenuLabel =
368
  activeMenu === "analysis-agent" ? "Analysis Agent" : activeMenu === "knowledge" ? "Knowledge" : "Home";
369
 
370
+ const isActiveAnalysisUnusable = staleSources.length > 0;
371
+
372
  const chatPane = (
373
  <main className="flex h-full min-h-0 min-w-0 flex-col bg-slate-50">
374
+ <AnalysisHeader
375
+ analysis={activeAnalysis}
376
+ staleSources={staleSources}
377
+ onUpdateDataBind={handleUpdateDataBind}
378
+ unusable={isActiveAnalysisUnusable}
379
+ />
380
  <div className="min-h-0 flex-1 overflow-y-auto overflow-x-hidden overscroll-contain">
381
  {loadingMessages ? (
382
  <div className="flex h-full items-center justify-center text-sm text-slate-500">Loading messages</div>
 
391
  )}
392
  </div>
393
  {messages.length > 0 && (
394
+ <SuggestedQuestionsBar
395
+ questions={activeAnalysis?.business_questions ?? []}
396
+ onSelect={handleSend}
397
+ disabled={isActiveAnalysisUnusable}
398
+ />
399
  )}
400
+ <ChatInput
401
+ disabled={!activeAnalysis || isActiveAnalysisUnusable}
402
+ streaming={streamState !== "idle"}
403
+ onSend={handleSend}
404
+ onHelp={handleHelp}
405
+ />
406
  </main>
407
  );
408
 
 
426
  onCollapse={() => setReportCollapsed(true)}
427
  fullscreen={reportFullscreen}
428
  onToggleFullscreen={() => setReportFullscreen((value) => !value)}
429
+ disabled={isActiveAnalysisUnusable}
430
  />
431
  </div>
432
  );
 
538
  className="absolute inset-0 bg-slate-950/40"
539
  />
540
  <div className="absolute inset-x-0 bottom-0 max-h-[86vh] overflow-y-auto rounded-t-xl bg-white p-4 shadow-2xl">
541
+ <ReportSidebar
542
+ analysis={activeAnalysis}
543
+ userId={session?.user_id}
544
+ onCollapse={() => setMobileReportOpen(false)}
545
+ disabled={isActiveAnalysisUnusable}
546
+ />
547
  </div>
548
  </div>
549
  )}
 
573
  </div>
574
  </div>
575
 
576
+ <NewAnalysisDialog open={newAnalysisOpen} onClose={() => setNewAnalysisOpen(false)} onCreated={handleCreated} existingAnalyses={analyses} />
577
  </div>
578
  );
579
  }
src/app/components/analysis/NewAnalysisDialog.tsx CHANGED
@@ -16,13 +16,20 @@ function formatCreateAnalysisError(err: unknown) {
16
  }
17
  return message;
18
  }
 
 
 
 
 
 
19
  interface NewAnalysisDialogProps {
20
  open: boolean;
21
  onClose: () => void;
22
  onCreated: (analysis: Analysis) => void;
 
23
  }
24
 
25
- export function NewAnalysisDialog({ open, onClose, onCreated }: NewAnalysisDialogProps) {
26
  const [title, setTitle] = useState("");
27
  const [objective, setObjective] = useState("");
28
  const [questions, setQuestions] = useState<string[]>(["", ""]);
@@ -32,7 +39,14 @@ export function NewAnalysisDialog({ open, onClose, onCreated }: NewAnalysisDialo
32
 
33
  if (!open) return null;
34
 
35
- const canSubmit = title.trim() && objective.trim() && compactQuestions(questions).length >= 2 && dataBind.length > 0;
 
 
 
 
 
 
 
36
 
37
  const submit = async (event: React.FormEvent) => {
38
  event.preventDefault();
@@ -83,6 +97,9 @@ export function NewAnalysisDialog({ open, onClose, onCreated }: NewAnalysisDialo
83
  placeholder="Q3 revenue movement"
84
  className="w-full rounded-lg border border-slate-200 px-3 py-2 text-sm outline-none focus:border-emerald-500 focus:ring-2 focus:ring-emerald-100"
85
  />
 
 
 
86
  </div>
87
 
88
  <div className="space-y-1.5">
 
16
  }
17
  return message;
18
  }
19
+ function isDuplicateName(candidate: string, existing: Analysis[]): boolean {
20
+ const normalized = candidate.trim().toLowerCase();
21
+ if (!normalized) return false;
22
+ return existing.some((a) => a.analysis_title.trim().toLowerCase() === normalized);
23
+ }
24
+
25
  interface NewAnalysisDialogProps {
26
  open: boolean;
27
  onClose: () => void;
28
  onCreated: (analysis: Analysis) => void;
29
+ existingAnalyses: Analysis[];
30
  }
31
 
32
+ export function NewAnalysisDialog({ open, onClose, onCreated, existingAnalyses }: NewAnalysisDialogProps) {
33
  const [title, setTitle] = useState("");
34
  const [objective, setObjective] = useState("");
35
  const [questions, setQuestions] = useState<string[]>(["", ""]);
 
39
 
40
  if (!open) return null;
41
 
42
+ const duplicateName = isDuplicateName(title, existingAnalyses);
43
+
44
+ const canSubmit =
45
+ title.trim() &&
46
+ !duplicateName &&
47
+ objective.trim() &&
48
+ compactQuestions(questions).length >= 2 &&
49
+ dataBind.length > 0;
50
 
51
  const submit = async (event: React.FormEvent) => {
52
  event.preventDefault();
 
97
  placeholder="Q3 revenue movement"
98
  className="w-full rounded-lg border border-slate-200 px-3 py-2 text-sm outline-none focus:border-emerald-500 focus:ring-2 focus:ring-emerald-100"
99
  />
100
+ {duplicateName && (
101
+ <p className="text-xs text-red-600">An analysis with this name already exists. Choose a different name.</p>
102
+ )}
103
  </div>
104
 
105
  <div className="space-y-1.5">
src/app/components/analysis/ReportSidebar.tsx CHANGED
@@ -20,9 +20,10 @@ interface ReportSidebarProps {
20
  onCollapse?: () => void;
21
  fullscreen?: boolean;
22
  onToggleFullscreen?: () => void;
 
23
  }
24
 
25
- export function ReportSidebar({ analysis, userId, onCollapse, fullscreen = false, onToggleFullscreen }: ReportSidebarProps) {
26
  const [versions, setVersions] = useState<ReportSummary[]>([]);
27
  const [selectedVersion, setSelectedVersion] = useState<number | undefined>();
28
  const [detail, setDetail] = useState<ReportDetail | null>(null);
@@ -135,7 +136,7 @@ export function ReportSidebar({ analysis, userId, onCollapse, fullscreen = false
135
  aria-label="Generate report"
136
  title="Generate a new report version with the Python report skill"
137
  onClick={handleGenerate}
138
- disabled={!analysis || !userId || generating}
139
  className="inline-flex items-center gap-2 rounded-md bg-slate-900 px-3 py-2 text-xs font-medium text-white hover:bg-slate-800 disabled:cursor-not-allowed disabled:opacity-50"
140
  >
141
  {generating && <Loader2 className="h-3.5 w-3.5 animate-spin" />}
 
20
  onCollapse?: () => void;
21
  fullscreen?: boolean;
22
  onToggleFullscreen?: () => void;
23
+ disabled?: boolean;
24
  }
25
 
26
+ export function ReportSidebar({ analysis, userId, onCollapse, fullscreen = false, onToggleFullscreen, disabled = false }: ReportSidebarProps) {
27
  const [versions, setVersions] = useState<ReportSummary[]>([]);
28
  const [selectedVersion, setSelectedVersion] = useState<number | undefined>();
29
  const [detail, setDetail] = useState<ReportDetail | null>(null);
 
136
  aria-label="Generate report"
137
  title="Generate a new report version with the Python report skill"
138
  onClick={handleGenerate}
139
+ disabled={!analysis || !userId || generating || disabled}
140
  className="inline-flex items-center gap-2 rounded-md bg-slate-900 px-3 py-2 text-xs font-medium text-white hover:bg-slate-800 disabled:cursor-not-allowed disabled:opacity-50"
141
  >
142
  {generating && <Loader2 className="h-3.5 w-3.5 animate-spin" />}
src/app/components/analysis/SuggestedQuestionsBar.tsx CHANGED
@@ -4,9 +4,10 @@ import { compactQuestions } from "./utils";
4
  interface SuggestedQuestionsBarProps {
5
  questions: string[];
6
  onSelect: (question: string) => void;
 
7
  }
8
 
9
- export function SuggestedQuestionsBar({ questions, onSelect }: SuggestedQuestionsBarProps) {
10
  const filtered = compactQuestions(questions);
11
  if (filtered.length === 0) return null;
12
 
@@ -18,9 +19,10 @@ export function SuggestedQuestionsBar({ questions, onSelect }: SuggestedQuestion
18
  <button
19
  key={question}
20
  type="button"
21
- onClick={() => onSelect(question)}
 
22
  title={question}
23
- className="flex-shrink-0 whitespace-nowrap rounded-full border border-slate-200 bg-white px-3 py-1 text-xs text-slate-600 transition hover:border-emerald-300 hover:bg-emerald-50 hover:text-emerald-800"
24
  >
25
  {question}
26
  </button>
 
4
  interface SuggestedQuestionsBarProps {
5
  questions: string[];
6
  onSelect: (question: string) => void;
7
+ disabled?: boolean;
8
  }
9
 
10
+ export function SuggestedQuestionsBar({ questions, onSelect, disabled }: SuggestedQuestionsBarProps) {
11
  const filtered = compactQuestions(questions);
12
  if (filtered.length === 0) return null;
13
 
 
19
  <button
20
  key={question}
21
  type="button"
22
+ onClick={() => { if (!disabled) onSelect(question); }}
23
+ disabled={disabled}
24
  title={question}
25
+ className="flex-shrink-0 whitespace-nowrap rounded-full border border-slate-200 bg-white px-3 py-1 text-xs text-slate-600 transition hover:border-emerald-300 hover:bg-emerald-50 hover:text-emerald-800 disabled:cursor-not-allowed disabled:opacity-40 disabled:hover:border-slate-200 disabled:hover:bg-white disabled:hover:text-slate-600"
26
  >
27
  {question}
28
  </button>