Files

120 lines
8.5 KiB
Markdown

# APP-1915: Tech Spec
## Context
See `PRODUCT.md` for user-visible behavior. The feature branch was rewritten before this spec was finalized, so the diff against `master` is additive only — it introduces the hover-aware plumbing rather than removing a buggy short-circuit.
Implementation anchors in the current code:
- `app/src/ai/blocklist/block.rs (3910-3936)` — new `AIBlock::hovered_rich_content_link`. Reads `detected_links_state.currently_hovered_link_location` and maps the underlying `DetectedLinkType` into a `RichContentLink` (`Url` or, under `#[cfg(feature = "local_fs")]`, `FilePath { absolute_path, line_and_column_num, target_override }`).
- `app/src/terminal/view.rs:14252` — new `TerminalView::hovered_rich_content_link_for_view`, a thin wrapper that resolves the `EntityId` back to the `AIBlock` handle and delegates to `AIBlock::hovered_rich_content_link`.
- `app/src/terminal/view.rs (14262-14803)``context_menu_items`. The existing top match at `(14269-14340)` continues to handle the terminal grid `highlighted_link` path unchanged. The `RichContentBlockRightClick` branch at `(14712-14803)` is the AI block path; this is where the hovered link is computed and threaded through.
- `app/src/terminal/view.rs (15514-15655)``ai_block_copying_menu_items`, which builds the Copy group (Copy → Copy prompt → Copy output as Markdown → conditional Copy command / Copy git branch → Save as prompt → Share conversation → Copy conversation text). The link-specific item is inserted immediately after "Copy output as Markdown" and before the conditional Copy command / git branch items.
- `RichContentLink` enum (in `view.rs`): `Url(String)` and `#[cfg(feature = "local_fs")] FilePath { absolute_path, line_and_column_num, target_override }`. `ContextMenuAction::CopyUrl { url_content }` is reused for both variants — for `FilePath`, the absolute path is copied via `to_string_lossy().into_owned()`.
There are two callers of `ai_block_copying_menu_items` in `view.rs`, both updated to accept the new `Option<RichContentLink>` parameter:
- `view.rs:14723` — right-click on an AI block (`BlockListMenuSource::RichContentBlockRightClick`). Passes the computed `Some(link)` when the cursor is over a hyperlink, `None` otherwise.
- `view.rs:15716``open_ai_block_overflow_context_menu`, triggered by the three-dot overflow button on an AI block. This surface has no hovered-link concept, so it always passes `None`.
`RichContentTextRightClick` (selection-active right-click in an AI block) intentionally does not participate: it is handled by a different arm in `context_menu_items` and builds the selection-oriented menu, per `PRODUCT.md` Behavior 8.
## Proposed changes
1. **Add `AIBlock::hovered_rich_content_link`** (`app/src/ai/blocklist/block.rs`). Returns `Option<RichContentLink>` by reading the already-maintained `detected_links_state.currently_hovered_link_location` and mapping the underlying `DetectedLinkType` into the `RichContentLink` variants the terminal view already understands.
2. **Add `TerminalView::hovered_rich_content_link_for_view`** (`app/src/terminal/view.rs`). Resolves the `EntityId` to the AI block handle via the existing `ai_block_handle_by_view_id` helper and delegates to `AIBlock::hovered_rich_content_link`.
3. **Add an `Option<RichContentLink>` parameter to `ai_block_copying_menu_items`.** When `Some`, push exactly one additional `MenuItem` immediately after "Copy output as Markdown" (before the conditional "Copy command" / "Copy git branch"):
```rust path=null start=null
if let Some(link) = hovered_link {
match link {
RichContentLink::Url(url) => items.push(
MenuItemFields::new("Copy URL")
.with_on_select_action(TerminalAction::ContextMenu(
ContextMenuAction::CopyUrl { url_content: url },
))
.into_item(),
),
#[cfg(feature = "local_fs")]
RichContentLink::FilePath { absolute_path, .. } => items.push(
MenuItemFields::new("Copy path")
.with_on_select_action(TerminalAction::ContextMenu(
ContextMenuAction::CopyUrl {
url_content: absolute_path.to_string_lossy().into_owned(),
},
))
.into_item(),
),
}
}
```
4. **Update both callers of `ai_block_copying_menu_items`:**
- `view.rs:14723` in the `RichContentBlockRightClick` branch — compute the hovered link once and pass it through:
```rust path=null start=null
let hovered_link = self.hovered_rich_content_link_for_view(*rich_content_view_id, ctx);
items.extend(self.ai_block_copying_menu_items(
*rich_content_view_id,
ai_metadata.conversation_id,
hovered_link.clone(),
&model,
ctx,
));
```
- `view.rs:15716` in `open_ai_block_overflow_context_menu` — always pass `None` (the overflow button has no hover context).
5. **Intentionally skip `RichContentTextRightClick`.** That branch fires only when a text selection is active (see `block_list_element.rs (1417-1428)`) and `PRODUCT.md` Behavior 8 keeps the selection-oriented menu unchanged.
## End-to-end flow
```mermaid
sequenceDiagram
participant User
participant BlockList as BlockListElement
participant View as TerminalView
participant AIBlock as AIBlock
participant Clipboard
User->>BlockList: Right-click on URL inside AI response
BlockList->>View: BlockListMenuSource::RichContentBlockRightClick
View->>View: hovered_rich_content_link_for_view()
View->>AIBlock: hovered_rich_content_link()
AIBlock-->>View: Some(RichContentLink::Url(url))
View->>View: ai_block_copying_menu_items(..., Some(link), ...)
Note over View: Inserts "Copy URL" after "Copy output as Markdown"
View-->>User: Full AI block menu + Copy URL
User->>View: Click "Copy URL"
View->>Clipboard: Write url
```
## Risks and mitigations
1. **Menu ordering regressions.** Insertion is strictly after "Copy output as Markdown" and before "Copy command" / "Copy git branch"; `PRODUCT.md` Behavior 6 pins the order. Manual validation confirms it.
2. **`local_fs` feature gating.** The "Copy path" branch stays behind `#[cfg(feature = "local_fs")]` to match the existing `RichContentLink::FilePath` variant. Covered by `PRODUCT.md` Behavior 4.
3. **Selection path.** `RichContentTextRightClick` does not receive the new item. Intentional per `PRODUCT.md` Behavior 8; a link-specific path during selection can be added as a follow-up if needed.
## Testing and validation
Each `PRODUCT.md` Behavior invariant maps to a concrete verification step:
- Behavior 1, 2, 6: Manual — open an AI response with a URL list (similar to the APP-1915 screenshot), right-click a URL, confirm the full AI block menu is shown with "Copy URL" inserted immediately after "Copy output as Markdown" and before any "Copy command" / "Copy git branch". Click it and confirm the clipboard contains the URL verbatim.
- Behavior 3, 4: Manual on a `local_fs` build — right-click a file-path link in an AI response, confirm "Copy path" is in the same position and copies the absolute path. On a non-`local_fs` build, confirm no "Copy path" item appears and the rest of the menu is unchanged.
- Behavior 5: Manual — hover a link, right-click, confirm the link-specific item appears exactly once and that "Copy URL" and "Copy path" never appear together.
- Behavior 7: Manual — right-click in an AI response body away from any link; menu matches the pre-regression baseline with no link-specific item.
- Behavior 8: Manual — with a text selection inside an AI response, right-click and confirm the selection-oriented menu is unchanged.
- Behavior 9: Manual — right-click a URL in the terminal grid; grid link menu is unchanged.
- Behavior 10: The new conditional push is guarded by `Option::Some`, so a missing hovered link cannot panic. A small regression test in `view_test.rs` that asserts "Copy URL" is present when a hovered URL link is set — and absent otherwise — is recommended alongside the manual checks.
- Behavior 11: Implicitly covered — menu items are built from hover state at the moment the menu is opened; no mutation of an already-open menu.
Existing `view_test.rs` coverage for `RichContentBlockRightClick` must continue to pass.
## Follow-ups
- Optional: add "Open link" or "Open in editor" items for file paths in AI responses, to reach parity with the grid link menu.
- Optional: add a hovered-link path for `RichContentTextRightClick` if user feedback asks for it.