diff --git a/.claude/commands/refine-requirements.md b/.claude/commands/refine-requirements.md new file mode 100644 index 0000000..41f6558 --- /dev/null +++ b/.claude/commands/refine-requirements.md @@ -0,0 +1,165 @@ +# Refine Requirements — Interactive Requirements Engineering + +Take a plan file and refine its requirements through structured exploration, interactive Q&A with ASCII mockups, and play-pretend walkthroughs that surface gaps naturally. + +**Input:** `$ARGUMENTS` is the path to the plan file (e.g. `PYRAMIDIZE.md`, `docs/plan.md`). + +If `$ARGUMENTS` is empty, use `AskUserQuestion` to ask: "Which plan file should I refine? (relative path from project root)" + +--- + +## Rules + +- **Max 3-4 questions per round.** Never wall-of-text the user. +- **Never assume — ask when ambiguous.** A wrong assumption costs more than a question. +- **Never copy v1 blindly.** If there's prior art, question whether old decisions still apply. +- **Always show, don't tell.** Every question with a UI or layout implication gets an ASCII mockup. Abstract descriptions are not acceptable — make it concrete. +- **Stay in character during Phase 3.** Narrate as if the feature exists. Break character only to surface a gap, then resume. +- **Requirements and design only.** Never ask about implementation details (JSON parsing, HTTP clients, DI wiring, test scaffolding) — those are the developer's domain. +- **Capture decisions immediately.** After each round, note what was decided before moving on. + +--- + +## Phase 1 — Deep Exploration (silent) + +Do all of this silently. Do NOT output anything to the user yet. + +1. Read the plan file at `$ARGUMENTS`. +2. Read `CLAUDE.md` and any architecture docs it references (`.claude/docs/architecture.md`, `.claude/docs/testing.md`, etc.). +3. Search the codebase for existing implementations related to the plan's feature area — look at the code, not just file names. Check for archived or previous versions if referenced. +4. Read any related Angular components, Go services, and shared utilities that the feature will touch or extend. +5. Build a mental model of: + - What exists today that this feature builds on + - What constraints the current architecture imposes + - What patterns the codebase already uses (and should be followed) + - Where the plan has gaps, ambiguities, or implicit assumptions + +When done, output a single short message: "I've explored the codebase and the plan. Starting requirements review — Phase 2." + +--- + +## Phase 2 — Structured Requirements Review + +Go through the plan section by section. For each section that has decisions to make or ambiguities to resolve: + +1. Present 3-4 questions (never more per round). +2. Every question MUST include: + - **2-4 concrete options** (labelled A, B, C, D) + - **ASCII preview mockup** for any option that affects layout, UI, or user-visible behaviour + - **Your recommendation** with a brief rationale (1 sentence) +3. Use `AskUserQuestion` to collect the user's choices. +4. After each round, summarize decisions made in a compact list before moving to the next section. + +Example question format: +``` +**Q2: How should the error state appear?** + +Option A — Inline below the action area: +┌─────────────────────────────────────┐ +│ [Action Button] │ +│ ❌ Step 2/3 failed: timeout. │ +│ [Retry] [Settings →] │ +└─────────────────────────────────────┘ + +Option B — Toast notification: +┌─────────────────────────────────────┐ +│ [Action Button] │ +│ ┌──────────────┐ │ +│ │ ❌ Timeout │ │ +│ │ [Retry] │ │ +│ └──────────────┘ │ +└─────────────────────────────────────┘ + +Recommendation: A — keeps error context near the action. +``` + +Continue until all sections have been reviewed. Then announce: "Requirements review complete. Moving to play-pretend walkthrough — Phase 3." + +--- + +## Phase 3 — Play-Pretend Walkthrough + +Walk through the feature as if it's already built and shipping. You narrate in present tense. The user is the product owner / architect — you ask them requirements and design questions, never code-level implementation details. + +### How to narrate + +Speak as if you're a QA tester or product reviewer using the finished feature for the first time: + +> "I open the app and navigate to the Pyramidize page. The left panel shows a doc type selector set to AUTO, a style dropdown, and a relationship dropdown. Below them is a large Pyramidize button with a Ctrl+Enter hint. The canvas area is empty — I see a placeholder with ghost text showing a sample pyramidized email..." + +### When to pause and ask + +Pause the narration whenever: +- The spec doesn't say what should happen → surface the gap +- Two requirements seem to conflict → ask which takes priority +- A behaviour feels wrong from a UX perspective → propose an alternative +- An edge case isn't covered → ask for the desired behaviour + +When pausing, break character briefly: + +> "**Gap found:** The spec doesn't say what happens when the user clicks Pyramidize again while the canvas already has edits from a previous run. Should it: +> A) Overwrite canvasText with the new result (edits lost) +> B) Ask 'Re-pyramidize from original? Your canvas edits will be lost' with [Yes] [No] +> C) Create a new trace entry and overwrite silently (edits recoverable via trace log) +> +> Recommendation: C — edits are never truly lost thanks to the trace log." + +Then use `AskUserQuestion` to get the decision, note it, and resume narrating. + +### Minimum scenarios to walk through + +Cover ALL of these angles (not just UI walkthroughs): + +1. **Happy path** — the golden scenario, start to finish +2. **First-time user** — no config, no presets, empty state +3. **Returning user** — presets exist, muscle memory, what's faster now +4. **Error / timeout** — API fails mid-pipeline, what does the user see and do +5. **Interruption / cancel** — user cancels during processing, closes mid-edit +6. **Edge cases** — empty input, very long input, mixed languages, rapid repeated actions +7. **State & lifecycle** — navigate away and back, minimize to tray, close window +8. **Hotkey vs manual** — differences in flow, what's available vs hidden +9. **Architecture choices** — new service vs extending existing, separate route vs same page, shared state +10. **Scope boundaries** — "this could grow into X — is X in scope or deferred?" + +When all scenarios are covered, announce: "Play-pretend walkthrough complete. Moving to gap resolution — Phase 4." + +--- + +## Phase 4 — Gap Resolution + +1. Compile all remaining gaps, open questions, and ambiguities discovered during Phases 2 and 3. +2. Present them as a numbered list, grouped by theme (UI, behaviour, state, error handling, scope). +3. Ask in batches of 3-4 using `AskUserQuestion`. +4. For each gap, either: + - Resolve it with the user's decision, OR + - Mark it explicitly as **out of scope** with a reason + +Continue until all gaps are resolved or marked out-of-scope. Then announce: "All gaps resolved. Updating the plan — Phase 5." + +--- + +## Phase 5 — Document Update + +1. Update the plan file (`$ARGUMENTS`) with all decisions made during this session: + - Add/modify requirement entries + - Update scoping decisions table + - Update out-of-scope table + - Add any new sections needed (e.g., new user stories, new NFRs) + - Add a "Last updated" timestamp +2. Present a completion checklist: + +``` +Requirements Refinement Complete +──────────────────────────────── +✅ Plan explored and understood +✅ N questions resolved across M rounds +✅ K scenarios walked through +✅ J gaps resolved, L marked out-of-scope +✅ Plan file updated: [filename] + +Want to do one more round? (e.g., "walk through the admin scenario" or "what about offline mode?") +``` + +3. Use `AskUserQuestion` to ask if the user wants one more round. + - If yes: return to Phase 3 or Phase 4 as appropriate, then repeat Phase 5. + - If no: end the session. diff --git a/.gitignore b/.gitignore index a2f4e61..87e376b 100644 --- a/.gitignore +++ b/.gitignore @@ -5,5 +5,7 @@ frontend/dist frontend/node_modules build/linux/appimage/build build/windows/nsis/MicrosoftEdgeWebview2Setup.exe +test-data/eval-runs/ +.superpowers/ .claude/settings.local.json -frontend/test-results \ No newline at end of file +frontend/test-results diff --git a/CLAUDE.md b/CLAUDE.md index a5ae2b9..737016d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -6,7 +6,8 @@ **Key directories:** - `main.go` — Wails entry point, service registration, event loop -- `internal/features/` — vertical slices: settings, shortcut, clipboard, tray, enhance, welcome, logger, updater +- `internal/features/` — vertical slices: settings, shortcut, clipboard, tray, enhance, welcome, logger, updater, pyramidize +- `internal/cli/` — headless CLI commands (`-fix`, `-pyramidize`), dispatched from `main.go` before Wails boots - `internal/app/wire.go` + `wire_gen.go` — Wire DI (never edit `wire_gen.go` manually) - `frontend/src/app/core/wails.service.ts` — sole RPC bridge; all Go calls go through here - `frontend/src/app/features/` — folder-per-component, subcomponents in nested folders → See `.claude/rules/architecture.md#component-structure` @@ -15,6 +16,33 @@ **Build / run / test:** `cd frontend && npm run build` (Angular), `go build -o bin/KeyLint .`, `wails3 dev` (hot-reload). Tests: `cd frontend && npm test` (Vitest), `go test ./internal/...` (Go), `cd frontend && npx playwright test` (E2E, needs `ng serve` on :4200). → See `.claude/rules/workflows.md` for post-change steps. +**CLI commands (headless, no GUI):** +``` +./bin/KeyLint -fix "text to fix" # silent grammar fix +./bin/KeyLint -fix -f input.txt # fix from file +cat input.txt | ./bin/KeyLint -fix # fix from stdin +./bin/KeyLint -pyramidize -type email -f input.md # pyramidize from file +./bin/KeyLint -pyramidize --json -f input.md # JSON output with quality score +./bin/KeyLint -pyramidize --provider claude --model claude-sonnet-4-6 -f input.md +./bin/KeyLint -pyramidize --variant 1 -f input.md # use prompt variant v1 +./bin/KeyLint -pyramidize --variant 2 -f input.md # use prompt variant v2 (0=latest) +``` + +**Evaluation tests (real API calls — NOT run by default):** +``` +# Requires .env with ANTHROPIC_API_KEY (or OPENAI_API_KEY) in project root. +# Uses //go:build eval tag — never included in normal `go test` runs. +# Results are logged to test-data/eval-runs// with summary.json. +go test -tags eval ./internal/features/pyramidize/ -v -timeout 300s +EVAL_PROVIDER=claude go test -tags eval ./internal/features/pyramidize/ -v -timeout 600s +EVAL_PROVIDER=claude EVAL_MODEL=claude-sonnet-4-6 go test -tags eval ... +./scripts/eval.sh # automated eval with summary +./scripts/eval.sh --provider claude --model claude-sonnet-4-6 +./scripts/eval.sh --variant 1 # compare v1 vs v2 prompts +EVAL_VARIANT=2 go test -tags eval ... # variant via env var +./scripts/eval-human.sh # interactive human review mode +``` + ## Why (The Context) KeyLint is a desktop app that fixes/enhances clipboard text via AI (OpenAI, Anthropic, Ollama, Bedrock). A global hotkey silently grabs clipboard text, enhances it, and writes it back. The main UI provides manual fix and advanced enhancement modes. @@ -23,6 +51,8 @@ KeyLint is a desktop app that fixes/enhances clipboard text via AI (OpenAI, Anth - AI API calls go through the Go backend (`internal/features/enhance/service.go:1`) — WebKit2GTK on Linux blocks external HTTPS fetch from the webview - API keys stored in OS keyring (`github.com/zalando/go-keyring`); env vars take priority over keyring — see `internal/features/settings/service.go` - PrimeNG Stepper was replaced with a custom `@switch`-based wizard (`welcome-wizard.component.ts`) because PrimeNG v21 StepPanel animations broke DOM visibility +- CLI mode (`-fix`, `-pyramidize`) dispatches before Wails boots in `main.go`, uses the same service layer with manual wiring (no Wire/Wails). Prompts are identical between CLI and GUI — output formatting is the caller's concern. +- Evaluation tests use `//go:build eval` build tag to isolate from normal `go test` runs. They make real API calls and write results to `test-data/eval-runs//`. **Component flow:** `main.go` → Wire DI initializes services → Wails registers them → `wails3 generate bindings` generates JS → `wails.service.ts` wraps bindings → Angular components call `WailsService` @@ -54,3 +84,5 @@ KeyLint is a desktop app that fixes/enhances clipboard text via AI (OpenAI, Anth **Rules (active steering):** `.claude/rules/architecture.md`, `.claude/rules/testing.md`, `.claude/rules/workflows.md` **Reference docs:** `.claude/docs/architecture.md` (service wiring, RPC bridge, platform differences), `.claude/docs/testing.md` (detailed patterns), `.claude/docs/versioning.md` (release pipeline, CI) + +**Pyramidize docs:** `docs/pyramidize/` (requirements, ADR, quality status, NLP/LangChain research, UX roadmap) diff --git a/README.md b/README.md index 0c3cae6..f405a8e 100644 --- a/README.md +++ b/README.md @@ -101,6 +101,27 @@ go test ./internal/... npx playwright test ``` +### Evaluation (Pyramidize Quality) + +The pyramidize feature has an automated eval pipeline that measures output quality against baseline test data using deterministic checks (structure, info coverage, hallucination detection) and LLM-as-judge scoring. + +```bash +# Setup: create .env in project root with your API key +echo "ANTHROPIC_API_KEY=sk-ant-..." > .env + +# Run eval (uses //go:build eval tag — isolated from normal tests) +EVAL_PROVIDER=claude go test -tags eval ./internal/features/pyramidize/ -v -timeout 600s + +# Or use the wrapper script (supports --provider / --model flags) +./scripts/eval.sh --provider claude + +# Interactive human review mode (side-by-side comparison) +./scripts/eval-human.sh --provider claude + +# Results are logged to test-data/eval-runs// +# Each run produces: summary.json, results.jsonl, samples/ +``` + ### Wire DI Regeneration ```bash @@ -116,7 +137,8 @@ KeyLint/ ├── main.go # Entry point — CLI flags, Wails app setup ├── internal/ │ ├── app/ # Wire DI (wire.go + wire_gen.go) -│ └── features/ # Vertical slices: settings, shortcut, clipboard, tray, enhance, welcome +│ ├── cli/ # Headless CLI commands (-fix, -pyramidize) +│ └── features/ # Vertical slices: settings, shortcut, clipboard, tray, enhance, welcome, pyramidize ├── frontend/ │ ├── src/app/ │ │ ├── core/ # WailsService (bindings bridge), MessageBus, guards diff --git a/TODO.md b/TODO.md index 04521e7..1399eb7 100644 --- a/TODO.md +++ b/TODO.md @@ -1,96 +1,33 @@ # KeyLint — Feature Parity TODO -Audit of gaps between v1 (Rust/Tauri) and v2 (Go/Wails). -Focus: the two core features — **Silent Fix** and **Pyramidize**. - ---- - -## System Tray & Window Lifecycle - -- [x] **Minimize to tray on close** — `ApplicationShouldTerminateAfterLastWindowClosed: false` set in - `main.go`; window-close event calls `window.Hide()`. - -- [x] **Tray icon click / double-click brings window to front** — `tray.OnClick` and - `tray.OnDoubleClick` handlers added in `internal/features/tray/service.go`. +Remaining gaps between v1 (Rust/Tauri) and v2 (Go/Wails). +For Pyramidize-specific status, see `docs/pyramidize/`. --- ## Silent Fix -- [x] **Auto-paste to source app** — `PasteToForeground` implemented on both platforms: - Windows via Win32 `SendInput` (`paste_windows.go`), Linux via `xdotool` (`paste_linux.go`). - +- [x] **Auto-paste to source app** — `PasteToForeground` on both platforms - [ ] **Linux hotkey** — currently a no-op stub (`service_linux.go`). Wire up a real global shortcut (e.g. `github.com/robotn/gohook` or `xbindkeys` integration). - -- [ ] **HTML clipboard support** — detect foreground app (Outlook, Word, LibreOffice, etc.), +- [ ] **HTML clipboard support** — detect foreground app (Outlook, Word, LibreOffice), convert Markdown output to HTML, write both CF_HTML and CF_TEXT to clipboard. - v1 had `HtmlClipboardService` with app-name regex matching. - ---- - -## Version & Updates - -- [x] **Version + update indicator in main nav** *(v4.0.0-alpha finding)* — display the app version - in small text at the bottom-left of the shell nav alongside a single icon that lights up when - an update is available. Clicking the icon (or version text) should navigate to Settings → About. - The version string is already available via `wails.getVersion()`; update status via - `wails.checkForUpdate()`. Currently only visible in Settings → About. --- -## Pyramidize (Advanced Mode) - -The current `TextEnhancementComponent` is a single-pass generic fix with no pyramidal logic. -The entire v1 `PyramidalAgentService` pipeline needs to be rebuilt in Go + Angular. - -### Pipeline (Generate → Specialists → QA) - -- [ ] **Document type detection** — LLM classifies input as EMAIL / WIKI / POWERPOINT / MEMO - (or user selects manually). Returns `{type, language, confidence}`. - -- [ ] **Oneshot foundation generator** — document-type-specific prompt templates (German + English) - that convert raw text into a structured document: subject + headers + body. - Output: `{subject, headers[], fullDocument, documentType, language}`. - -- [ ] **Parallel specialist agents** — run concurrently after the foundation step: - - Subject Line Specialist — validates format + information density - - Header Structure Specialist — MECE principle + pyramidal hierarchy - - Information Completeness Specialist — detects info loss vs original - - Style & Language Specialist — tone, consistency, professional polish - - Each returns a confidence score (0.0–1.0). - -- [ ] **Integration coordinator** — selectively applies specialist improvements where - confidence > 0.7; preserves baseline on low-confidence suggestions. - -- [ ] **Quality assurance check** — final pass returns - `{informationLoss[], accuracyIssues[], missingElements[], overallScore, passed}`. - -### UI Controls (missing from v2) - -- [ ] Document type selector (AUTO / EMAIL / WIKI / POWERPOINT / MEMO) -- [ ] Communication style selector (concise / detailed / persuasive / neutral / - diplomatic / direct / casual / professional) -- [ ] Relationship level selector (formal / professional / casual / friendly) -- [ ] Custom instructions textarea -- [ ] Markdown rendering for output (replace readonly ` - +
+ + +
+ + @if (!bannerDismissedView && !apiKeySet) { +
+ ⚠ No AI API key configured. + +
+ } + + +
+ + +
+ +
+ + +
+ +
+ + + @if (detectedTypeView) { +
+ + {{ detectedTypeView }} +
+ } +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ + + + Pyramidize + Ctrl+↵ + + + + +
+ + @if (advancedOpenView) { +
+
+ +
+ + 0–1 +
+ Scores below this trigger a refinement pass (default 0.65). +
+
+ } +
-
- - - + +
+ + @if (isLoading) { + +
+ + {{ stepLabel }} + +
+ } @else { +
+ + + Original + Editor + + + + +
+ @if (!originalTextView) { +
+

Paste or type text to pyramidize.

+ +
+ } + +
+
+ + + +
+
+ + +
+ + @if (isPreviewModeView) { +
+ } @else { +
+ +
+ } + + + @if (showSelectionBubble && !isPreviewModeView) { +
+ + + +
+ } +
+
+
+
+
+ } + + + @if (errorMessage) { +
+ ❌ {{ errorMessage }} + + +
+ } + + + @if (refinementWarning) { +
+ ⚠ {{ refinementWarning }} +
+ } + + +
+ + + + Apply + Ctrl+↵ + + +
+ + +
+ + + @if (sourceAppView) { + + } +
+ + + @if (activeEntry) { +
+
+
+ {{ activeEntry.label }} + {{ formatTime(activeEntry.timestamp) }} + @if (!peekEntry) { + Click to pin + } + @if (peekEntry) { + + } +
+
{{ activeEntry.snapshot }}
+ +
+
+ }
- @if (error) { - - } + +
+ @if (traceLogOpenView) { +
+ Trace Log + + +
+
+ @for (entry of traceLogView; track entry.id) { +
+ {{ entry.label }} + {{ formatTime(entry.timestamp) }} +
+ } +
+ } @else { +
+ +
+ } +
`, styles: [` - .enhance-page { display: flex; flex-direction: column; gap: 1rem; padding: 2.75rem; height: 100%; box-sizing: border-box; } - .hint-text { margin: 0; font-size: 0.875rem; color: var(--p-text-muted-color); } - .enhance-textareas { display: flex; gap: 2.75rem; flex: 1; } - .enhance-textarea { flex: 1; resize: none; min-height: 200px; } - .enhance-actions { display: flex; gap: 0.5rem; } + :host { display: block; height: 100%; } + + .pyramidize-page { + display: flex; + flex-direction: row; + height: 100%; + overflow: hidden; + gap: 0; + } + + /* ── Left panel ── */ + .left-panel { + width: 280px; + min-width: 280px; + display: flex; + flex-direction: column; + gap: 0.75rem; + padding: 1rem; + border-right: 1px solid var(--p-content-border-color); + overflow-y: auto; + } + + .api-key-banner { + background: var(--p-amber-100, #fef3c7); + color: var(--p-amber-900, #78350f); + border: 1px solid var(--p-amber-300, #fcd34d); + border-radius: 6px; + padding: 0.5rem 0.75rem; + font-size: 0.8rem; + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.5rem; + } + + .detection-badge { + display: flex; + align-items: center; + gap: 0.3rem; + font-size: 0.75rem; + color: var(--p-primary-color); + font-weight: 600; + letter-spacing: 0.05em; + margin-top: 0.25rem; + } + .detection-dot { font-size: 0.6rem; } + + .form-group { + display: flex; + flex-direction: column; + gap: 0.3rem; + } + label { + font-size: 0.8rem; + color: var(--p-text-muted-color); + } + + .custom-instructions-textarea { + width: 100%; + resize: vertical; + font-size: 0.85rem; + } + + .pyramidize-btn-full { + width: 100%; + } + .pyramidize-btn-full ::ng-deep button { + width: 100%; + justify-content: space-between; + } + + .shortcut-hint { + font-size: 0.7rem; + opacity: 0.6; + margin-left: 0.5rem; + } + + /* Advanced section (UX-04) */ + .advanced-section { + border-top: 1px solid var(--p-content-border-color); + padding-top: 0.5rem; + margin-top: 0.25rem; + } + .advanced-toggle { + background: none; + border: none; + cursor: pointer; + display: flex; + align-items: center; + gap: 0.4rem; + font-size: 0.8rem; + color: var(--p-text-muted-color); + padding: 0.2rem 0; + width: 100%; + } + .advanced-toggle:hover { color: var(--p-text-color); } + .advanced-body { padding-top: 0.5rem; } + .threshold-row { + display: flex; + align-items: center; + gap: 0.4rem; + } + .threshold-input { + width: 80px !important; + font-size: 0.85rem; + } + .threshold-hint { font-size: 0.75rem; color: var(--p-text-muted-color); } + .hint-text { font-size: 0.78rem; color: var(--p-text-muted-color); margin: 0; } + + /* ── Canvas area ── */ + .canvas-area { + flex: 1; + display: flex; + flex-direction: column; + overflow: hidden; + padding: 1rem; + gap: 0.75rem; + min-width: 0; + min-height: 0; + position: relative; + } + + .step-indicator { + display: flex; + align-items: center; + gap: 1rem; + padding: 1rem; + background: var(--p-content-hover-background); + border-radius: 8px; + } + .step-spinner { width: 24px; height: 24px; } + .step-label { flex: 1; font-size: 0.9rem; } + + /* Tabs container — must flex-grow to fill available space (UX-07) */ + .tabs-container { + flex: 1; + overflow: hidden; + min-height: 0; + display: flex; + flex-direction: column; + } + .tabs-container ::ng-deep .p-tabs { + flex: 1; + display: flex; + flex-direction: column; + overflow: hidden; + min-height: 0; + } + /* display:flex on the CONTAINER (.p-tabpanels) is safe — it does not make hidden + panels visible. The [hidden] attribute sets display:none on each inactive + .p-tabpanel element itself, so those children don't participate in flex layout. + Without display:flex here the active panel's flex:1 has no effect (flex + properties only work inside a flex formatting context). */ + .tabs-container ::ng-deep .p-tabpanels { + flex: 1; + overflow: hidden; + min-height: 0; + display: flex; + flex-direction: column; + } + /* Active panel fills the .p-tabpanels flex container. + We must NOT set display on the generic .p-tabpanel selector — that would + override the UA-stylesheet display:none applied via the [hidden] attribute + on inactive panels and make them visible simultaneously. */ + .tabs-container ::ng-deep .p-tabpanel:not([hidden]) { + flex: 1; + overflow: hidden; + min-height: 0; + display: flex; + flex-direction: column; + } + + .tab-panel-content { + flex: 1; + overflow: hidden; + min-height: 0; + display: flex; + flex-direction: column; + gap: 0.5rem; + position: relative; + } + + .empty-original { + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + display: flex; + flex-direction: column; + align-items: center; + gap: 0.75rem; + pointer-events: none; + z-index: 1; + } + .empty-original p-button { pointer-events: all; } + + /* Canvas textarea and preview fill remaining height (UX-07) */ + .canvas-textarea { + flex: 1; + min-height: 0; + resize: none; + width: 100%; + font-family: var(--p-font-family); + font-size: 0.9rem; + line-height: 1.6; + } + + .canvas-mode-toggle { + display: flex; + gap: 0.5rem; + flex-shrink: 0; + } + + .canvas-preview { + flex: 1; + min-height: 0; + padding: 1rem; + border: 1px solid var(--p-content-border-color); + border-radius: 6px; + overflow-y: auto; + line-height: 1.7; + } + .canvas-preview ::ng-deep h1 { font-size: 1.4rem; margin: 0.5rem 0; } + .canvas-preview ::ng-deep h2 { font-size: 1.2rem; margin: 0.5rem 0; } + .canvas-preview ::ng-deep h3 { font-size: 1rem; margin: 0.4rem 0; } + .canvas-preview ::ng-deep p { margin: 0.4rem 0; } + .canvas-preview ::ng-deep ul, .canvas-preview ::ng-deep ol { padding-left: 1.5rem; margin: 0.4rem 0; } + .canvas-preview ::ng-deep code { + background: var(--p-content-hover-background); + padding: 1px 4px; + border-radius: 3px; + font-family: monospace; + font-size: 0.85em; + } + + .canvas-edit-wrapper { + flex: 1; + min-height: 0; + display: flex; + flex-direction: column; + position: relative; + } + + /* Selection bubble */ + .selection-bubble { + position: fixed; + background: var(--p-surface-overlay, var(--p-surface-card)); + border: 1px solid var(--p-content-border-color); + border-radius: 8px; + padding: 0.5rem; + display: flex; + gap: 0.4rem; + align-items: center; + z-index: 1000; + box-shadow: 0 4px 16px rgba(0,0,0,0.25); + } + .bubble-input { width: 180px; font-size: 0.85rem; } + + /* Error row — clipped to 2 lines (UX-03) */ + .error-row { + display: flex; + align-items: center; + gap: 0.5rem; + color: var(--p-red-400, #f87171); + font-size: 0.85rem; + padding: 0.5rem 0.75rem; + background: var(--p-content-hover-background); + border-radius: 6px; + flex-shrink: 0; + } + .error-text { + flex: 1; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; + word-break: break-word; + cursor: default; + } + + .refinement-warning { + font-size: 0.8rem; + color: var(--p-amber-400, #fbbf24); + padding: 0.4rem 0.5rem; + flex-shrink: 0; + } + + /* Instruction bar */ + .instruction-bar { + display: flex; + gap: 0.5rem; + align-items: center; + border-top: 1px solid var(--p-content-border-color); + padding-top: 0.75rem; + flex-shrink: 0; + } + .instruction-input { flex: 1; } + + /* Action row */ + .action-row { + display: flex; + gap: 0.5rem; + flex-wrap: wrap; + flex-shrink: 0; + } + + /* Trace peek overlay — covers the canvas area (UX-06) */ + .trace-peek-overlay { + position: absolute; + inset: 0; + background: rgba(0,0,0,0.45); + display: flex; + align-items: stretch; + z-index: 50; + padding: 0.75rem; + } + .trace-peek-panel { + flex: 1; + background: var(--p-surface-card, var(--p-surface-900)); + border: 1px solid var(--p-content-border-color); + border-radius: 8px; + display: flex; + flex-direction: column; + overflow: hidden; + } + .trace-peek-header { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.75rem 1rem; + border-bottom: 1px solid var(--p-content-border-color); + flex-shrink: 0; + } + .trace-peek-title { flex: 1; font-weight: 600; font-size: 0.9rem; } + .trace-peek-time { font-size: 0.75rem; color: var(--p-text-muted-color); } + .trace-peek-hint { font-size: 0.7rem; color: var(--p-text-muted-color); font-style: italic; } + .trace-peek-content { + flex: 1; + overflow-y: auto; + white-space: pre-wrap; + word-break: break-word; + padding: 1rem; + margin: 0; + font-size: 0.85rem; + line-height: 1.6; + font-family: var(--p-font-family); + } + .trace-peek-footer { + display: flex; + gap: 0.5rem; + padding: 0.75rem 1rem; + border-top: 1px solid var(--p-content-border-color); + flex-shrink: 0; + } + + /* ── Trace log panel ── */ + .trace-panel { + width: 260px; + min-width: 260px; + border-left: 1px solid var(--p-content-border-color); + display: flex; + flex-direction: column; + overflow: hidden; + transition: width 0.2s ease; + } + .trace-panel.collapsed { + width: 42px; + min-width: 42px; + } + + .trace-icon-strip { + display: flex; + flex-direction: column; + align-items: center; + padding: 0.5rem 0; + } + + .trace-header { + display: flex; + align-items: center; + gap: 0.25rem; + padding: 0.5rem 0.75rem; + border-bottom: 1px solid var(--p-content-border-color); + } + .trace-title { flex: 1; font-size: 0.8rem; font-weight: 600; } + + .trace-list { + flex: 1; + overflow-y: auto; + padding: 0.25rem 0; + } + + .trace-entry { + display: flex; + flex-direction: column; + padding: 0.4rem 0.75rem; + cursor: pointer; + border-bottom: 1px solid var(--p-content-border-color); + transition: background 0.1s; + } + .trace-entry:hover { background: var(--p-content-hover-background); } + .trace-entry.active { background: var(--p-highlight-background); } + .trace-label { font-size: 0.8rem; } + .trace-time { font-size: 0.7rem; color: var(--p-text-muted-color); } `], }) export class TextEnhancementComponent implements OnInit, OnDestroy { - inputText = ''; - outputText = ''; - loading = false; - error = ''; + // ── Component views (mirror module-level state) ── + get originalTextView(): string { return originalText; } + set originalTextView(v: string) { originalText = v; } + + get canvasTextView(): string { return canvasText; } + set canvasTextView(v: string) { canvasText = v; } + + get docTypeView(): string { return docType; } + set docTypeView(v: string) { docType = v; } + + get commStyleView(): string { return commStyle; } + set commStyleView(v: string) { commStyle = v; } + + get relLevelView(): string { return relLevel; } + set relLevelView(v: string) { relLevel = v; } + + get traceLogView(): TraceEntry[] { return traceLog; } + + get activeTabView(): string { return activeTab; } + + get isPreviewModeView(): boolean { return isPreviewMode; } + + get traceLogOpenView(): boolean { return traceLogOpen; } + + get sourceAppView(): string { return sourceApp; } + + get bannerDismissedView(): boolean { return bannerDismissed; } + + get providerView(): string { return selectedProvider; } + set providerView(v: string) { selectedProvider = v; } + + get modelView(): string { return selectedModel; } + set modelView(v: string) { selectedModel = v; } + + get qualityThresholdView(): number { return qualityThreshold; } + set qualityThresholdView(v: number) { qualityThreshold = v; } + + get advancedOpenView(): boolean { return advancedOpen; } + + // ── Component-local state ── + isLoading = false; + stepLabel = ''; + errorMessage = ''; + refinementWarning = ''; + apiKeySet = true; + customInstructions = ''; + globalInstruction = ''; + detectedTypeView = ''; + + // Selection bubble + showSelectionBubble = false; + bubbleX = 0; + bubbleY = 0; + selectionInstruction = ''; + private selectionStart = 0; + private selectionEnd = 0; + + // Trace peek — peekEntry is sticky (click), hoverEntry is transient (mouseenter) + peekEntry: TraceEntry | null = null; + hoverEntry: TraceEntry | null = null; + + get activeEntry(): TraceEntry | null { return this.peekEntry ?? this.hoverEntry; } + + // Retry state + private lastRequest: (() => Promise) | null = null; private sub?: Subscription; + @ViewChild('canvasTextarea') canvasTextareaRef?: ElementRef; + + readonly providerOptions = PROVIDER_OPTIONS; + + get currentModelOptions(): Array<{ label: string; value: string }> { + return PROVIDER_MODELS[selectedProvider] ?? PROVIDER_MODELS['claude']; + } + + readonly docTypeOptions = [ + { label: 'AUTO (detect)', value: 'auto' }, + ...DOCUMENT_TYPE_OPTIONS, + ]; + + readonly commStyleOptions = [ + { label: 'Professional', value: 'professional' }, + { label: 'Casual', value: 'casual' }, + { label: 'Concise', value: 'concise' }, + { label: 'Detailed', value: 'detailed' }, + { label: 'Persuasive', value: 'persuasive' }, + { label: 'Neutral', value: 'neutral' }, + { label: 'Diplomatic', value: 'diplomatic' }, + { label: 'Direct', value: 'direct' }, + ]; + + readonly relLevelOptions = [ + { label: 'Professional', value: 'professional' }, + { label: 'Close', value: 'close' }, + { label: 'Authority', value: 'authority' }, + { label: 'Public', value: 'public' }, + ]; + constructor( private readonly wails: WailsService, private readonly svc: TextEnhancementService, private readonly cdr: ChangeDetectorRef, + private readonly router: Router, ) {} - ngOnInit(): void { - // Listen for shortcut events from backend — auto-populate from clipboard. + async ngOnInit(): Promise { + sourceApp = await this.wails.getSourceApp(); + const settings = await this.wails.loadSettings(); + + // Initialise provider from settings if not already set this session + if (!selectedProvider && settings.active_provider) { + selectedProvider = settings.active_provider; + selectedModel = DEFAULT_MODELS[selectedProvider] ?? 'claude-sonnet-4-6'; + } + + const keyStatus = await this.wails.getKeyStatus(selectedProvider); + this.apiKeySet = keyStatus.is_set; + + qualityThreshold = await this.wails.getQualityThreshold(); + + this.cdr.detectChanges(); + this.sub = this.wails.shortcutTriggered$.subscribe(async () => { - this.inputText = await this.wails.readClipboard(); + const clipboardContent = await this.wails.readClipboard(); + sourceApp = await this.wails.getSourceApp(); + + if (originalText && !confirm('Replace current session with new clipboard content?')) { + return; + } + + wasCancelled = false; + originalText = clipboardContent; + pyramidizedText = ''; + canvasText = ''; + traceLog = []; + this.detectedTypeView = ''; + activeTab = 'original'; + this.errorMessage = ''; + this.refinementWarning = ''; + + if (originalText.trim()) { + addTrace('Original', originalText); + } this.cdr.detectChanges(); - await this.enhance(); }); } - async enhance(): Promise { - if (!this.inputText.trim()) return; - this.loading = true; - this.error = ''; + onOriginalChange(value: string): void { + originalText = value; + } + + onCanvasChange(value: string): void { + canvasText = value; + } + + onDocTypeChange(): void { + if (docType !== 'auto') { + this.detectedTypeView = ''; + } + } + + onProviderChange(): void { + // Reset model to default for new provider + selectedModel = DEFAULT_MODELS[selectedProvider] ?? ''; + } + + onTabChange(value: unknown): void { + activeTab = value as 'original' | 'canvas'; + } + + setPreviewMode(preview: boolean): void { + isPreviewMode = preview; + } + + toggleTraceLog(): void { + traceLogOpen = !traceLogOpen; + this.peekEntry = null; + } + + toggleAdvanced(): void { + advancedOpen = !advancedOpen; + } + + dismissBanner(): void { + bannerDismissed = true; + } + + async saveThreshold(): Promise { + try { + await this.wails.setQualityThreshold(qualityThreshold); + } catch { + // best-effort + } + } + + onOriginalKeydown(event: KeyboardEvent): void { + if (event.ctrlKey && event.key === 'Enter') { + event.preventDefault(); + void this.pyramidize(); + } + } + + onCanvasKeydown(event: KeyboardEvent): void { + if (event.ctrlKey && event.key === 'Enter') { + event.preventDefault(); + void this.applyGlobalInstruction(); + } + } + + async pasteFromClipboard(): Promise { + const text = await this.wails.readClipboard(); + originalText = text; + if (originalText.trim()) { + addTrace('Original', originalText); + } + this.cdr.detectChanges(); + } + + async pyramidize(): Promise { + if (!originalText.trim()) return; + + if (canvasText.trim()) { + if (!confirm('Re-pyramidize? The current editor content will be saved to the trace log.')) { + return; + } + addTrace('Editor (saved)', canvasText); + } + + wasCancelled = false; + this.errorMessage = ''; + this.refinementWarning = ''; + this.isLoading = true; + this.stepLabel = 'Step 1/2: Detecting…'; + this.cdr.detectChanges(); + + const req = { + text: originalText, + documentType: docType, + communicationStyle: commStyle, + relationshipLevel: relLevel, + customInstructions: this.customInstructions, + provider: selectedProvider, + model: selectedModel, + promptVariant: 0, + }; + + const doCall = async (): Promise => { + this.stepLabel = docType === 'auto' ? 'Step 1/2: Detecting…' : 'Step 1/2: Structuring…'; + this.cdr.detectChanges(); + + const result = await this.svc.pyramidize(req); + + if (docType === 'auto' && result.detectedType) { + this.detectedTypeView = result.detectedType.toUpperCase(); + this.stepLabel = 'Step 2/2: Structuring…'; + this.cdr.detectChanges(); + } + + pyramidizedText = result.fullDocument; + canvasText = result.fullDocument; + this.refinementWarning = result.refinementWarning ?? ''; + + addTrace('Pyramidized', canvasText); + activeTab = 'canvas'; + }; + + this.lastRequest = async () => { + this.isLoading = true; + this.errorMessage = ''; + this.stepLabel = 'Step 1/2: Detecting…'; + this.cdr.detectChanges(); + try { + await doCall(); + } finally { + this.isLoading = false; + this.cdr.detectChanges(); + } + }; + try { - this.outputText = await this.svc.enhance(this.inputText); + await doCall(); } catch (e: unknown) { - this.error = e instanceof Error ? e.message : String(e); + if (!wasCancelled) { + this.errorMessage = `Pyramidize failed: ${e instanceof Error ? e.message : String(e)}`; + } + } finally { + this.isLoading = false; + this.cdr.detectChanges(); + } + } + + async cancelOperation(): Promise { + wasCancelled = true; + await this.svc.cancelOperation(); + this.isLoading = false; + this.stepLabel = ''; + this.cdr.detectChanges(); + } + + async applyGlobalInstruction(): Promise { + if (!this.globalInstruction.trim() || !canvasText.trim()) return; + + const instruction = this.globalInstruction; + this.lastRequest = () => this.applyGlobalInstruction(); + this.isLoading = true; + this.stepLabel = 'Refining…'; + this.errorMessage = ''; + wasCancelled = false; + this.cdr.detectChanges(); + + try { + const result = await this.svc.refineGlobal({ + fullCanvas: canvasText, + originalText, + instruction, + documentType: docType, + communicationStyle: commStyle, + relationshipLevel: relLevel, + provider: selectedProvider, + model: selectedModel, + }); + addTrace(`Refined: "${instruction.slice(0, 30)}"`, canvasText); + canvasText = result.newCanvas; + this.globalInstruction = ''; + } catch (e: unknown) { + if (!wasCancelled) { + this.errorMessage = `Refine failed: ${e instanceof Error ? e.message : String(e)}`; + } + } finally { + this.isLoading = false; + this.cdr.detectChanges(); + } + } + + onCanvasMouseUp(event: MouseEvent): void { + const sel = window.getSelection(); + if (!sel || sel.isCollapsed || !sel.toString().trim()) { + this.showSelectionBubble = false; + this.cdr.detectChanges(); + return; + } + + const textarea = this.canvasTextareaRef?.nativeElement; + if (textarea) { + this.selectionStart = textarea.selectionStart; + this.selectionEnd = textarea.selectionEnd; + } + + this.showSelectionBubble = true; + this.bubbleX = event.clientX - 100; + this.bubbleY = event.clientY - 80; + this.selectionInstruction = ''; + this.cdr.detectChanges(); + } + + closeSelectionBubble(): void { + this.showSelectionBubble = false; + this.selectionInstruction = ''; + } + + async applySelectionInstruction(): Promise { + if (!this.selectionInstruction.trim()) return; + + const textarea = this.canvasTextareaRef?.nativeElement; + const start = textarea ? textarea.selectionStart : this.selectionStart; + const end = textarea ? textarea.selectionEnd : this.selectionEnd; + const selectedText = canvasText.slice(start, end); + + if (!selectedText.trim()) { + this.closeSelectionBubble(); + return; + } + + const instruction = this.selectionInstruction; + this.lastRequest = () => this.applySelectionInstruction(); + this.closeSelectionBubble(); + this.isLoading = true; + this.stepLabel = 'Splicing…'; + wasCancelled = false; + this.cdr.detectChanges(); + + try { + const result = await this.svc.splice({ + fullCanvas: canvasText, + originalText, + selectedText, + instruction, + provider: selectedProvider, + model: selectedModel, + }); + const before = canvasText.slice(0, start); + const after = canvasText.slice(end); + const oldCanvas = canvasText; + addTrace(`Splice: "${instruction.slice(0, 30)}"`, oldCanvas); + canvasText = before + result.rewrittenSection + after; + } catch (e: unknown) { + if (!wasCancelled) { + this.errorMessage = `Splice failed: ${e instanceof Error ? e.message : String(e)}`; + } } finally { - this.loading = false; + this.isLoading = false; + this.cdr.detectChanges(); + } + } + + addCheckpoint(): void { + if (canvasText) { + addTrace('Checkpoint', canvasText); this.cdr.detectChanges(); } } - async copyResult(): Promise { - await navigator.clipboard.writeText(this.outputText); + peekTrace(entry: TraceEntry): void { + this.peekEntry = entry; + this.hoverEntry = null; + this.cdr.detectChanges(); + } + + hoverTrace(entry: TraceEntry): void { + if (!this.peekEntry) { + this.hoverEntry = entry; + this.cdr.detectChanges(); + } + } + + clearHoverTrace(): void { + this.hoverEntry = null; + this.cdr.detectChanges(); + } + + closePeek(): void { + this.peekEntry = null; + this.hoverEntry = null; + } + + revertTo(entry: TraceEntry): void { + addTrace(`Reverted to: ${entry.label}`, canvasText); + canvasText = entry.snapshot; + this.peekEntry = null; + activeTab = 'canvas'; + this.cdr.detectChanges(); + } + + async copyAsMarkdown(): Promise { + await this.wails.writeClipboard(canvasText); + } + + async copyAsRichText(): Promise { + const pipe = new MarkdownPipe(); + const html = pipe.transform(canvasText); + const plain = canvasText; + try { + // Native Clipboard API required here for HTML MIME type support + // (WailsService.writeClipboard only handles plain text) + await navigator.clipboard.write([ + new ClipboardItem({ + 'text/html': new Blob([html], { type: 'text/html' }), + 'text/plain': new Blob([plain], { type: 'text/plain' }), + }), + ]); + } catch { + await this.wails.writeClipboard(plain); + } + } + + async copyError(): Promise { + await this.wails.writeClipboard(this.errorMessage); + } + + async sendBack(): Promise { + await this.svc.sendBack(canvasText); + } + + async retry(): Promise { + if (this.lastRequest) { + await this.lastRequest(); + } } - async writeToClipboard(): Promise { - await this.wails.writeClipboard(this.outputText); + formatTime(d: Date): string { + return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' }); } ngOnDestroy(): void { diff --git a/frontend/src/app/features/text-enhancement/text-enhancement.service.spec.ts b/frontend/src/app/features/text-enhancement/text-enhancement.service.spec.ts index 1d5110d..51c5452 100644 --- a/frontend/src/app/features/text-enhancement/text-enhancement.service.spec.ts +++ b/frontend/src/app/features/text-enhancement/text-enhancement.service.spec.ts @@ -2,17 +2,28 @@ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; import { TestBed } from '@angular/core/testing'; import { TextEnhancementService } from './text-enhancement.service'; import { WailsService } from '../../core/wails.service'; -import { createWailsMock, defaultSettings } from '../../../testing/wails-mock'; +import { createWailsMock } from '../../../testing/wails-mock'; describe('TextEnhancementService', () => { let svc: TextEnhancementService; let wailsMock: ReturnType; - let fetchSpy: ReturnType; + + const mockPyramidizeResult = { + documentType: 'EMAIL', + language: 'en', + fullDocument: 'Body text', + headers: [], + qualityScore: 0.9, + qualityFlags: [], + appliedRefinement: false, + refinementWarning: '', + detectedType: 'EMAIL', + detectedLang: 'en', + detectedConfidence: 0.95, + }; beforeEach(() => { wailsMock = createWailsMock(); - // Default: backend enhance succeeds - wailsMock.enhance.mockResolvedValue('Backend enhanced text.'); TestBed.configureTestingModule({ providers: [ @@ -21,71 +32,99 @@ describe('TextEnhancementService', () => { ], }); svc = TestBed.inject(TextEnhancementService); - - fetchSpy = vi.spyOn(globalThis, 'fetch'); }); afterEach(() => { vi.restoreAllMocks(); }); - // --- Primary path: Go backend --- + // ── Legacy enhance() path ── - it('delegates to wails.enhance() and returns the result', async () => { + it('enhance() delegates to wails.enhance() and returns the result', async () => { + wailsMock.enhance.mockResolvedValue('Backend enhanced text.'); const result = await svc.enhance('bad grammer'); expect(wailsMock.enhance).toHaveBeenCalledWith('bad grammer'); expect(result).toBe('Backend enhanced text.'); - expect(fetchSpy).not.toHaveBeenCalled(); }); - it('propagates backend errors to the caller', async () => { - wailsMock.enhance.mockRejectedValue(new Error('Anthropic API key is not configured')); - await expect(svc.enhance('text')).rejects.toThrow('Anthropic API key is not configured'); + // ── Pyramidize delegation ── + + it('pyramidize() delegates to wails.pyramidize()', async () => { + wailsMock.pyramidize.mockResolvedValue(mockPyramidizeResult); + const req = { text: 'hello', documentType: 'auto', communicationStyle: 'professional', relationshipLevel: 'professional', customInstructions: '', provider: 'claude', model: 'claude-sonnet-4-6', promptVariant: 0 }; + const result = await svc.pyramidize(req); + expect(wailsMock.pyramidize).toHaveBeenCalledWith(req); + expect(result).toEqual(mockPyramidizeResult); }); - // --- Browser-mode fallback path (Wails runtime unavailable) --- + // ── RefineGlobal delegation ── - it('falls back to direct OpenAI fetch when Wails runtime is unavailable', async () => { - // Simulate Wails runtime not initialised (synchronous-style error message) - wailsMock.enhance.mockRejectedValue(new Error('Call is not a function (wails runtime)')); - wailsMock.loadSettings.mockResolvedValue({ ...defaultSettings, active_provider: 'openai' }); - wailsMock.getKey.mockResolvedValue('sk-test-key'); + it('refineGlobal() delegates to wails.refineGlobal()', async () => { + const mockResult = { newCanvas: 'Refined text' }; + wailsMock.refineGlobal.mockResolvedValue(mockResult); + const req = { fullCanvas: 'canvas', originalText: 'orig', instruction: 'shorter', documentType: 'email', communicationStyle: 'professional', relationshipLevel: 'professional', provider: 'claude', model: 'claude-sonnet-4-6' }; + const result = await svc.refineGlobal(req); + expect(wailsMock.refineGlobal).toHaveBeenCalledWith(req); + expect(result).toEqual(mockResult); + }); + + // ── Splice delegation ── - const mockResponse = { choices: [{ message: { content: 'OpenAI fixed.' } }] }; - fetchSpy.mockResolvedValue(new Response(JSON.stringify(mockResponse), { status: 200 })); + it('splice() delegates to wails.splice()', async () => { + const mockResult = { rewrittenSection: 'New section' }; + wailsMock.splice.mockResolvedValue(mockResult); + const req = { fullCanvas: 'canvas', originalText: 'orig', selectedText: 'selected', instruction: 'rewrite', provider: 'claude', model: 'claude-sonnet-4-6' }; + const result = await svc.splice(req); + expect(wailsMock.splice).toHaveBeenCalledWith(req); + expect(result).toEqual(mockResult); + }); - const result = await svc.enhance('bad text'); + // ── CancelOperation delegation ── - expect(fetchSpy).toHaveBeenCalledWith( - 'https://api.openai.com/v1/chat/completions', - expect.objectContaining({ method: 'POST' }), - ); - expect(result).toBe('OpenAI fixed.'); + it('cancelOperation() delegates to wails.cancelOperation()', async () => { + await svc.cancelOperation(); + expect(wailsMock.cancelOperation).toHaveBeenCalled(); }); - it('falls back to direct Ollama fetch when Wails runtime is unavailable', async () => { - wailsMock.enhance.mockRejectedValue(new Error('wails runtime not available')); - wailsMock.loadSettings.mockResolvedValue({ - ...defaultSettings, - active_provider: 'ollama', - providers: { ollama_url: 'http://localhost:11434', aws_region: '' }, - }); - const mockResponse = { response: 'Ollama fixed.' }; - fetchSpy.mockResolvedValue(new Response(JSON.stringify(mockResponse), { status: 200 })); + // ── AppPresets delegation ── - const result = await svc.enhance('some text'); + it('getAppPresets() delegates to wails.getAppPresets()', async () => { + const mockPresets = [{ sourceApp: 'Outlook', documentType: 'email' }]; + wailsMock.getAppPresets.mockResolvedValue(mockPresets); + const result = await svc.getAppPresets(); + expect(wailsMock.getAppPresets).toHaveBeenCalled(); + expect(result).toEqual(mockPresets); + }); + + it('setAppPreset() delegates to wails.setAppPreset()', async () => { + const preset = { sourceApp: 'Outlook', documentType: 'email' }; + await svc.setAppPreset(preset); + expect(wailsMock.setAppPreset).toHaveBeenCalledWith(preset); + }); + + it('deleteAppPreset() delegates to wails.deleteAppPreset()', async () => { + await svc.deleteAppPreset('Outlook'); + expect(wailsMock.deleteAppPreset).toHaveBeenCalledWith('Outlook'); + }); + + // ── QualityThreshold delegation ── + + it('getQualityThreshold() delegates to wails.getQualityThreshold()', async () => { + wailsMock.getQualityThreshold.mockResolvedValue(0.75); + const result = await svc.getQualityThreshold(); + expect(wailsMock.getQualityThreshold).toHaveBeenCalled(); + expect(result).toBe(0.75); + }); - expect(fetchSpy).toHaveBeenCalledWith( - 'http://localhost:11434/api/generate', - expect.objectContaining({ method: 'POST' }), - ); - expect(result).toBe('Ollama fixed.'); + it('setQualityThreshold() delegates to wails.setQualityThreshold()', async () => { + await svc.setQualityThreshold(0.8); + expect(wailsMock.setQualityThreshold).toHaveBeenCalledWith(0.8); }); - it('throws for unknown provider in browser fallback', async () => { - wailsMock.enhance.mockRejectedValue(new Error('wails runtime error')); - wailsMock.loadSettings.mockResolvedValue({ ...defaultSettings, active_provider: 'unknown' as never }); + // ── enhance() propagates errors ── - await expect(svc.enhance('text')).rejects.toThrow('Unknown provider: unknown'); + it('enhance() propagates backend errors to caller', async () => { + wailsMock.enhance.mockRejectedValue(new Error('API key not configured')); + await expect(svc.enhance('text')).rejects.toThrow('API key not configured'); }); }); diff --git a/frontend/src/app/features/text-enhancement/text-enhancement.service.ts b/frontend/src/app/features/text-enhancement/text-enhancement.service.ts index 9129797..893456b 100644 --- a/frontend/src/app/features/text-enhancement/text-enhancement.service.ts +++ b/frontend/src/app/features/text-enhancement/text-enhancement.service.ts @@ -1,104 +1,58 @@ import { Injectable } from '@angular/core'; -import { WailsService } from '../../core/wails.service'; +import { WailsService, PyramidizeRequest, PyramidizeResult, RefineGlobalRequest, RefineGlobalResult, SpliceRequest, SpliceResult, AppPreset } from '../../core/wails.service'; + +export type { PyramidizeRequest, PyramidizeResult, RefineGlobalRequest, RefineGlobalResult, SpliceRequest, SpliceResult, AppPreset }; @Injectable({ providedIn: 'root' }) export class TextEnhancementService { constructor(private readonly wails: WailsService) {} - async enhance(text: string): Promise { - try { - // Primary path: Go backend handles the API call (no WebView network-policy issues). - return await this.wails.enhance(text); - } catch (backendErr: unknown) { - // If the Wails runtime is unavailable (browser dev / Playwright mode) the call throws - // synchronously before returning a promise. Fall back to direct fetch in that case. - // In the real Wails app this branch is never reached — backend errors propagate as-is. - if (this.isWailsUnavailableError(backendErr)) { - return this.enhanceBrowserFallback(text); - } - throw backendErr; - } + pyramidize(req: PyramidizeRequest): Promise { + return this.wails.pyramidize(req); + } + + refineGlobal(req: RefineGlobalRequest): Promise { + return this.wails.refineGlobal(req); + } + + splice(req: SpliceRequest): Promise { + return this.wails.splice(req); + } + + cancelOperation(): Promise { + return this.wails.cancelOperation(); + } + + sendBack(text: string): Promise { + return this.wails.sendBack(text); } - // Returns true when the error indicates the Wails runtime is simply not present - // (browser dev mode), as opposed to a real backend error. - private isWailsUnavailableError(err: unknown): boolean { - const msg = err instanceof Error ? err.message : String(err); - return msg.includes('wails') || msg.includes('Call') || msg.includes('runtime'); + getSourceApp(): Promise { + return this.wails.getSourceApp(); } - // Browser-mode fallback: calls the AI provider API directly from the browser. - // Used by Playwright E2E tests (with page.route() CORS proxy) and ng-serve dev. - private async enhanceBrowserFallback(text: string): Promise { - const settings = await this.wails.loadSettings(); + getAppPresets(): Promise { + return this.wails.getAppPresets(); + } + + setAppPreset(preset: AppPreset): Promise { + return this.wails.setAppPreset(preset); + } - switch (settings.active_provider) { - case 'openai': - return this.callOpenAI(text, await this.wails.getKey('openai')); - case 'claude': - return this.callClaude(text); - case 'ollama': - return this.callOllama(text, settings.providers.ollama_url); - case 'bedrock': - throw new Error('AWS Bedrock is not yet supported. Please select a different provider.'); - default: - throw new Error(`Unknown provider: ${settings.active_provider}`); - } + deleteAppPreset(sourceApp: string): Promise { + return this.wails.deleteAppPreset(sourceApp); } - private async callOpenAI(text: string, apiKey: string): Promise { - if (!apiKey) throw new Error('OpenAI API key is not configured. Go to Settings → AI Providers to add it.'); - const res = await fetch('https://api.openai.com/v1/chat/completions', { - method: 'POST', - headers: { 'Authorization': `Bearer ${apiKey}`, 'Content-Type': 'application/json' }, - body: JSON.stringify({ - model: 'gpt-4o-mini', - messages: [ - { role: 'system', content: 'You are a professional text editor. Fix grammar, spelling, and improve clarity. Return only the improved text.' }, - { role: 'user', content: text }, - ], - }), - }); - if (!res.ok) throw new Error(`OpenAI error: ${res.status} ${res.statusText}`); - const json = await res.json() as { choices: Array<{ message: { content: string } }> }; - return json.choices[0].message.content; + getQualityThreshold(): Promise { + return this.wails.getQualityThreshold(); } - private async callClaude(text: string): Promise { - // In Playwright/browser-dev mode: read key from localStorage (injected by test). - const apiKey = typeof localStorage !== 'undefined' ? localStorage.getItem('_e2e_apikey_claude') ?? '' : ''; - if (!apiKey) throw new Error( - 'Anthropic API key is not configured for browser mode. ' + - 'In the desktop app the key is read from the OS keyring automatically.', - ); - const res = await fetch('https://api.anthropic.com/v1/messages', { - method: 'POST', - headers: { 'x-api-key': apiKey, 'anthropic-version': '2023-06-01', 'Content-Type': 'application/json' }, - body: JSON.stringify({ - model: 'claude-haiku-4-5-20251001', - max_tokens: 2048, - system: 'You are a professional text editor. Fix grammar, spelling, and improve clarity. Return only the improved text.', - messages: [{ role: 'user', content: text }], - }), - }); - if (!res.ok) throw new Error(`Claude error: ${res.status} ${res.statusText}`); - const json = await res.json() as { content: Array<{ text: string }> }; - return json.content[0].text; + setQualityThreshold(v: number): Promise { + return this.wails.setQualityThreshold(v); } - private async callOllama(text: string, baseUrl: string): Promise { - const base = baseUrl || 'http://localhost:11434'; - const res = await fetch(`${base}/api/generate`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - model: 'llama3.2', - prompt: `Fix grammar, spelling, and improve clarity. Return only the improved text.\n\nText: ${text}`, - stream: false, - }), - }); - if (!res.ok) throw new Error(`Ollama error: ${res.status} ${res.statusText}`); - const json = await res.json() as { response: string }; - return json.response; + // Keep enhance() for backward compatibility with remaining callers. + enhance(text: string): Promise { + return this.wails.enhance(text); } } diff --git a/frontend/src/app/layout/shell.component.scss b/frontend/src/app/layout/shell.component.scss index d98215a..4183f65 100644 --- a/frontend/src/app/layout/shell.component.scss +++ b/frontend/src/app/layout/shell.component.scss @@ -1,38 +1,94 @@ .layout-wrapper { - display: flex; + display: block; height: 100vh; overflow: hidden; + position: relative; + + // When collapsed, main content reserves only the 3rem strip. + // Hover-expand overlays without shifting the main content. + &.sidebar-collapsed .layout-main { + margin-left: 3rem; + } } .layout-sidebar { - width: 17rem; + position: absolute; + left: 0; + top: 0; height: 100%; + width: 17rem; + z-index: 50; display: flex; flex-direction: column; background: var(--p-surface-900, #18181b); border-right: 1px solid var(--p-surface-700, #3f3f46); box-shadow: 2px 0 6px rgba(0, 0, 0, 0.3); - flex-shrink: 0; + transition: width 0.2s ease; + overflow: hidden; + + &.collapsed { + width: 3rem; + + .nav-item a { + justify-content: center; + padding-left: 0; + padding-right: 0; + } + + .version-row { + padding: 0; + } + + // Collapsed (not hover-expanded): contract "ey" and "int" so only "K" and "L" show. + &:not(.hover-expanded) .layout-logo { + .logo-reveal { + max-width: 0; + } + } + + // Hover-expand: full sidebar overlays main content without layout shift. + &.hover-expanded { + width: 17rem; + + .nav-item a { + justify-content: flex-start; + padding-left: 1rem; + padding-right: 1rem; + } + + .version-row { + padding: 0.5rem 1rem; + } + } + } } .layout-logo { display: flex; align-items: center; + justify-content: center; padding: 1.5rem 1rem 1rem; border-bottom: 1px solid var(--p-surface-700, #3f3f46); + overflow: hidden; - .logo-text { + // Shared type styles for all logo character spans. + .logo-k, .logo-reveal, .logo-l { font-size: 1.25rem; font-weight: 700; letter-spacing: -0.025em; + line-height: 1; } - .logo-key { - color: var(--p-surface-50, #fff); - } + .logo-key { color: var(--p-surface-50, #fff); } + .logo-lint { color: var(--p-primary-color, #f97316); } - .logo-lint { - color: var(--p-primary-color, #f97316); + // "ey" and "int" expand in as the sidebar opens. + // max-width clips characters without affecting flex alignment of neighbours. + .logo-reveal { + overflow: hidden; + white-space: nowrap; + max-width: 4em; + transition: max-width 0.18s ease; } } @@ -40,6 +96,7 @@ flex: 1; padding: 0.75rem 0; overflow-y: auto; + overflow-x: hidden; ul { list-style: none; @@ -54,25 +111,41 @@ gap: 0.75rem; padding: 0.75rem 1rem; border-radius: 6px; - margin: 2px 0.75rem; + margin: 2px 0.5rem; color: var(--p-surface-200, #e4e4e7); text-decoration: none; font-size: 0.9rem; transition: background 0.15s, color 0.15s; + white-space: nowrap; + overflow: hidden; + + // line-height:1 on the text label keeps its box height = font-size (0.9rem), + // which is shorter than the icon height (1rem). This prevents the from + // growing taller when the label appears on hover-expand, which would cause + // align-items:center to re-center the icon — the visible 1-2px "drop" bug. + span { + line-height: 1; + } i { font-size: 1rem; width: 1.25rem; + min-width: 1.25rem; text-align: center; color: var(--p-surface-400, #a1a1aa); transition: color 0.15s; } + .nav-icon-svg { + min-width: 1rem; + transition: color 0.15s; + } + &:hover { background: var(--p-surface-800, #27272a); color: var(--p-surface-50, #fafafa); - i { + i, .nav-icon-svg { color: var(--p-primary-color, #f97316); } } @@ -81,20 +154,28 @@ background: var(--p-primary-color, #f97316); color: #fff; - i { + i, .nav-icon-svg { color: #fff; } } } .sidebar-footer { + display: flex; + flex-direction: column; + border-top: 1px solid var(--p-surface-700, #3f3f46); + overflow: hidden; +} + +.version-row { display: flex; align-items: center; gap: 0.5rem; - padding: 0.75rem 1rem; - border-top: 1px solid var(--p-surface-700, #3f3f46); + padding: 0.5rem 1rem; cursor: pointer; transition: background 0.15s; + white-space: nowrap; + overflow: hidden; &:hover { background: var(--p-surface-800, #27272a); @@ -112,13 +193,37 @@ } } +.collapse-btn { + background: none; + border: none; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + padding: 0.875rem 0.5rem; + color: var(--p-surface-400, #a1a1aa); + transition: background 0.15s, color 0.15s; + width: 100%; + + i { + font-size: 0.85rem; + } + + &:hover { + background: var(--p-surface-800, #27272a); + color: var(--p-surface-50, #fafafa); + } +} + @keyframes pulse-glow { 0%, 100% { opacity: 0.7; } 50% { opacity: 1; } } .layout-main { - flex: 1; + height: 100vh; overflow: auto; background: var(--p-surface-ground); + margin-left: 17rem; + transition: margin-left 0.2s ease; } diff --git a/frontend/src/app/layout/shell.component.ts b/frontend/src/app/layout/shell.component.ts index 3afd9ea..c14dd74 100644 --- a/frontend/src/app/layout/shell.component.ts +++ b/frontend/src/app/layout/shell.component.ts @@ -2,56 +2,91 @@ import { Component, OnInit, OnDestroy, ChangeDetectorRef } from '@angular/core'; import { Router, RouterOutlet, RouterLink, RouterLinkActive } from '@angular/router'; import { isDevMode } from '@angular/core'; import { Subscription } from 'rxjs'; +import { TooltipModule } from 'primeng/tooltip'; import { WailsService } from '../core/wails.service'; +// Persists across navigation +let sidebarCollapsed = false; +let sidebarHovered = false; + @Component({ selector: 'app-shell', standalone: true, - imports: [RouterOutlet, RouterLink, RouterLinkActive], + imports: [RouterOutlet, RouterLink, RouterLinkActive, TooltipModule], styleUrls: ['./shell.component.scss'], template: ` -
-