feat: attach desktop file paths as editable learner chips

This commit is contained in:
amosblomqvist
2026-10-28 18:54:57 +02:00
parent 84f93999c5
commit c4e934c9dc
30 changed files with 2331 additions and 59 deletions
+3 -2
View File
@@ -4,6 +4,7 @@ import { useState } from "react";
import { ConfidenceRail } from "@/components/confidence-rail";
import { GlyphMark } from "@/components/ui/glyph-mark";
import { ThinkingBlock } from "@/components/thinking-block";
import { PathText } from "@/components/file-path-chip";
import { cn } from "@/lib/utils";
import { ToolContentResponse } from "@/components/ai-elements/message";
import type { QuestionAnswerData } from "@/src/core";
@@ -153,13 +154,13 @@ export function AnsweredCard({ answer: savedAnswer, input }: { answer: QuestionA
data-testid="answered-other"
className="flex items-center gap-2 rounded-lg border border-primary bg-accent px-3 py-1.5"
>
<span>“{answer.other}”</span>
<span>“<PathText text={answer.other} />”</span>
</div>
)}
</div>
)}
{answer.text && <div className="lh-prose mt-2 whitespace-pre-wrap">“{answer.text}”</div>}
{answer.text && <div className="lh-prose mt-2 whitespace-pre-wrap">“<PathText text={answer.text} />”</div>}
{/* The confidence record (owner round 6): the slider's rail, disc
removed — the missing handle itself says "this is a record, not a
+7 -6
View File
@@ -14,6 +14,7 @@ import {
ConversationScrollButton,
} from "@/components/ai-elements/conversation";
import { Message, MessageContent, MessageResponse } from "@/components/ai-elements/message";
import { PathText } from "@/components/file-path-chip";
import { Reasoning, ReasoningContent, ReasoningTrigger } from "@/components/ai-elements/reasoning";
import { Shimmer } from "@/components/ai-elements/shimmer";
import { BrailleSpinner } from "@/components/braille-spinner";
@@ -687,10 +688,10 @@ export function Chat({
const cycleChatFields = useCallback((): readonly HTMLElement[] => {
const pick = (sel: string) => Array.from(document.querySelectorAll<HTMLElement>(sel));
return [
...pick('[data-testid="reading-thinking-field"] textarea'),
...pick('[data-testid="question-dock"] textarea'),
...pick('[data-testid="practice-dock"] textarea'),
...pick('[data-testid="composer"] textarea'),
...pick('[data-testid="reading-thinking-field"] [data-file-path-input]'),
...pick('[data-testid="question-dock"] [data-file-path-input]'),
...pick('[data-testid="practice-dock"] [data-file-path-input]'),
...pick('[data-testid="composer"] [data-file-path-input]'),
];
}, []);
useFieldCycle(cycleChatFields);
@@ -705,7 +706,7 @@ export function Chat({
* composer: with a question pending the Composer is unmounted, the ref
* is null, and the thinking field is left alone by design. */
const dictationWasActiveRef = useRef(false);
const composerInputRef = useRef<HTMLTextAreaElement>(null);
const composerInputRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (dictation.active) {
dictationWasActiveRef.current = true;
@@ -1507,7 +1508,7 @@ export function Chat({
rail as the thinking rows — one quote geometry. */}
<div className="font-medium uppercase tracking-wide text-muted-foreground">You</div>
<div className="lh-prose whitespace-pre-wrap text-foreground">
{m.parts.filter((p) => p.type === "text").map((p) => p.text).join("\n")}
<PathText text={m.parts.filter((p) => p.type === "text").map((p) => p.text).join("\n")} />
</div>
</div>
) : (
+5 -4
View File
@@ -3,6 +3,7 @@
import { ArrowRightIcon, CornerDownLeftIcon, SquareIcon } from "lucide-react";
import { cn } from "@/lib/utils";
import { Hint } from "@/components/ui/hint";
import { PathInput } from "@/components/path-input";
/**
* The chat composer — content row ONLY (the persistent rounded shell lives in
@@ -59,7 +60,7 @@ export function Composer({
autoFocus?: boolean;
/** The page's handle to the textarea — the dictation-end focus (owner,
* 2026-10-01): a run ending hands the cursor to the bare composer. */
inputRef?: React.RefObject<HTMLTextAreaElement | null>;
inputRef?: React.RefObject<HTMLDivElement | null>;
/** Escape while a dictation runs stops it (STT wave, 2026-09-17). No-op
* when absent — Esc otherwise means nothing in the composer. */
onEscape?: () => void;
@@ -90,10 +91,11 @@ export function Composer({
act();
}}
>
<textarea
<PathInput
ref={inputRef}
value={value}
onChange={(e) => onChange(e.target.value)}
onValueChange={onChange}
defaultDropTarget
onKeyDown={(e) => {
// Esc ends dictation before anything else Esc might someday mean
// here (the dictate decisions: click-again or Esc to stop).
@@ -115,7 +117,6 @@ export function Composer({
}}
placeholder={placeholder}
autoFocus={autoFocus}
rows={1}
className="field-sizing-content lh-input max-h-48 min-h-10 flex-1 resize-none bg-transparent px-2.5 py-2 outline-none placeholder:text-muted-foreground"
/>
{/* The button follows the text (owner report 2026-10-03): while
+18
View File
@@ -199,6 +199,17 @@ export function rmsToMeter(rms: number): number {
* window, one pending question, one target at a time.
*/
let questionTarget: ((text: string) => void) | null = null;
let composerTarget: ((text: string) => void) | null = null;
/** File references use the very same default destination as dictation,
* including claims whose dock is currently collapsed. A focused editor can
* insert at its caret instead; callers use this only as the fallback. */
export function appendToPrimaryInput(text: string): boolean {
const target = questionTarget ?? composerTarget;
if (!target) return false;
target(text);
return true;
}
/* The finisher registry: submit paths FAR from the hook's owner (the
* pending question's dock) need finish() without a prop bridge. Same
@@ -280,6 +291,13 @@ export function useDictation({ append }: { append: (text: string) => void }): Di
* retarget (composer↔question) is honored on the very next final. */
const appendRef = useRef(append);
appendRef.current = append;
useEffect(() => {
const target = (text: string) => appendRef.current(text);
composerTarget = target;
return () => {
if (composerTarget === target) composerTarget = null;
};
}, []);
// Live machinery (non-render values). The MediaStream tracks stop and
// the per-run nodes disconnect on teardown; the socket is recreated per
+62
View File
@@ -0,0 +1,62 @@
import React, { Fragment } from "react";
import {
File,
FileArchive,
FileAudio,
FileCode,
FileImage,
FileSpreadsheet,
FileText,
FileVideo,
} from "lucide-react";
import { filePathLabel, parseFilePaths } from "@/src/core";
function fileIcon(label: string) {
const extension = label.split(".").at(-1)?.toLowerCase();
if (/^(png|jpe?g|gif|webp|svg|bmp|tiff?|avif|heic)$/.test(extension ?? "")) return FileImage;
if (/^(mp3|wav|ogg|flac|m4a|aac)$/.test(extension ?? "")) return FileAudio;
if (/^(mp4|mov|webm|mkv|avi)$/.test(extension ?? "")) return FileVideo;
if (/^(zip|gz|tar|bz2|xz|7z|rar)$/.test(extension ?? "")) return FileArchive;
if (/^(csv|tsv|xlsx?|ods)$/.test(extension ?? "")) return FileSpreadsheet;
if (/^(js|jsx|ts|tsx|py|rb|rs|go|c|cpp|h|java|json|html|css|sh|yaml|yml)$/.test(extension ?? "")) return FileCode;
if (/^(pdf|txt|md|mdx|rtf|docx?|odt|tex|epub)$/.test(extension ?? "")) return FileText;
return File;
}
export interface FilePathChipProps {
path: string;
/** Editors can make the chip atomic; transcript renderers can omit this. */
contentEditable?: false;
className?: string;
}
/** Pure presentation: no links, open actions, HTML injection or path probing. */
export function FilePathChip({ path, contentEditable, className }: FilePathChipProps) {
const label = filePathLabel(path);
const Icon = fileIcon(label);
return (
<span
role="img"
aria-label={path}
title={path}
contentEditable={contentEditable}
className={`inline-flex max-w-full items-center gap-1 rounded border bg-muted/50 px-1.5 py-0.5 align-middle text-sm${className ? ` ${className}` : ""}`}
>
<Icon aria-hidden="true" className="size-3.5 shrink-0" />
<span className="min-w-0 truncate">{label}</span>
</span>
);
}
/** Ordinary learner text stays ordinary text; only quoted paths become chips. */
export function PathText({ text }: { text: string }) {
return (
<>
{parseFilePaths(text).map((part) => (
<Fragment key={part.start}>
{part.kind === "path" ? <FilePathChip path={part.path} /> : part.text}
</Fragment>
))}
</>
);
}
+106
View File
@@ -0,0 +1,106 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { FileSymlinkIcon, XIcon } from "lucide-react";
import { quoteFilePath } from "@/src/core";
import { appendToPrimaryInput } from "@/components/dictation";
import { INSERT_FILE_PATHS_EVENT } from "@/components/path-input";
const BROWSER_NOTICE = "Local file paths can only be attached in the desktop app. You can paste a full path instead.";
/** The content area only: the sidebar is deliberately outside this zone.
* Existing files stay where they are; only quoted path strings enter drafts. */
export function FilePathDropZone({ children, className }: { children: React.ReactNode; className?: string }) {
const root = useRef<HTMLElement>(null);
const depth = useRef(0);
const [dragging, setDragging] = useState(false);
const [notice, setNotice] = useState("");
const hasFiles = (event: React.DragEvent) => Array.from(event.dataTransfer.types).includes("Files");
const clearDrag = () => { depth.current = 0; setDragging(false); };
useEffect(() => {
// The sidebar remains a non-target, but dropping a file there must not
// navigate the app away. No indicator or attachment is produced there.
const preventOutsideNavigation = (event: DragEvent) => {
if (Array.from(event.dataTransfer?.types ?? []).includes("Files")
&& event.target instanceof Node && !root.current?.contains(event.target)) event.preventDefault();
};
window.addEventListener("dragover", preventOutsideNavigation);
window.addEventListener("drop", preventOutsideNavigation);
return () => {
window.removeEventListener("dragover", preventOutsideNavigation);
window.removeEventListener("drop", preventOutsideNavigation);
};
}, []);
return (
<main
ref={root}
className={className}
data-testid="file-path-drop-zone"
onDragEnter={(event) => {
if (!hasFiles(event)) return;
event.preventDefault();
depth.current++;
setDragging(true);
setNotice("");
}}
onDragOver={(event) => {
if (!hasFiles(event)) return;
event.preventDefault();
event.dataTransfer.dropEffect = "copy";
}}
onDragLeave={(event) => {
if (!hasFiles(event)) return;
depth.current = Math.max(0, depth.current - 1);
if (depth.current === 0) setDragging(false);
}}
onDragEnd={clearDrag}
onDrop={(event) => {
if (!hasFiles(event)) return;
event.preventDefault();
event.stopPropagation();
clearDrag();
const resolve = window.lh?.getPathForFile;
if (!resolve) { setNotice(BROWSER_NOTICE); return; }
// Directories are not file references. This check uses drag metadata,
// never a server request or a new filesystem capability.
const entries = Array.from(event.dataTransfer.items).filter((item) => item.kind === "file");
const paths: string[] = [];
let skipped = 0;
for (const [index, file] of Array.from(event.dataTransfer.files).entries()) {
try {
if (entries[index]?.webkitGetAsEntry?.()?.isDirectory) { skipped++; continue; }
const path = resolve(file);
quoteFilePath(path); // Same rooted-path validity rule as typed chips.
paths.push(path);
} catch { skipped++; }
}
if (paths.length === 0) {
setNotice("No local file paths were available. Drop existing files from your computer.");
return;
}
const focused = document.activeElement?.closest<HTMLElement>("[data-file-path-input]");
if (focused && event.currentTarget.contains(focused)) {
focused.dispatchEvent(new CustomEvent(INSERT_FILE_PATHS_EVENT, { detail: paths }));
} else if (!appendToPrimaryInput(paths.map(quoteFilePath).join(" "))) {
setNotice("Open a chat to attach file paths.");
return;
}
setNotice(skipped ? "File paths attached. Some dropped items had no available file path." : "File paths attached.");
}}
>
{children}
{dragging && (
<div data-testid="file-path-drop-indicator" className="pointer-events-none absolute inset-2 z-50 flex items-center justify-center rounded-xl border-2 border-dashed border-primary bg-background/85 p-6 text-center backdrop-blur-sm">
<div className="flex max-w-md flex-col items-center gap-3">
<FileSymlinkIcon className="size-8 text-primary" aria-hidden />
<span className="font-ui text-sm">{window.lh?.getPathForFile ? "Drop to attach file paths" : BROWSER_NOTICE}</span>
</div>
</div>
)}
{notice && <div role="status" className="absolute bottom-2 left-1/2 z-50 flex max-w-[90%] -translate-x-1/2 items-center gap-2 rounded-md border bg-card px-3 py-2 font-ui text-xs shadow-sm">
<span>{notice}</span>
<button type="button" aria-label="Dismiss attachment notice" onClick={() => setNotice("")} className="shrink-0 rounded-sm p-1 hover:bg-accent"><XIcon className="size-3" aria-hidden /></button>
</div>}
</main>
);
}
+7 -6
View File
@@ -13,6 +13,7 @@ import { useFieldCycle } from "@/components/field-cycle";
import { SidebarToggle } from "@/components/sidebar";
import { StatsBar } from "@/components/stats-bar";
import { OPENING_PROMPT_FAILURE } from "@/components/opening-prompt";
import { PathInput } from "@/components/path-input";
import { SessionStarter } from "@/components/start-session";
import type { TurnUsage } from "@/src/core";
@@ -64,7 +65,7 @@ export function Home({
/* Run end focuses the topic input (owner, 2026-10-01): the hands are on
* the keyboard from the stop. Falling edge only; this surface's only
* dictation target IS this textarea, so no case-split is needed. */
const topicInputRef = useRef<HTMLTextAreaElement>(null);
const topicInputRef = useRef<HTMLDivElement>(null);
// Tab navigates INTO the topic composer (owner, keyboard round
// 2026-10-06): the only text field on this surface — and home's
// composer is its OWN textarea, not the shared Composer component,
@@ -248,13 +249,14 @@ export function Home({
void start();
}}
>
<textarea
<PathInput
ref={topicInputRef}
value={topic}
onChange={(e) => {
topicRef.current = e.target.value;
setTopic(e.target.value);
onValueChange={(value) => {
topicRef.current = value;
setTopic(value);
}}
defaultDropTarget
onKeyDown={(e) => {
// Esc ends dictation (the dictate decisions: click-again or
// Esc) — stopPropagation claims it (keyboard wave A) so
@@ -272,7 +274,6 @@ export function Home({
}}
placeholder={mode === "practice" ? "What do you want to practice?" : "What do you want to learn?"}
autoFocus
rows={1}
className="field-sizing-content lh-input max-h-48 min-h-10 flex-1 resize-none bg-transparent px-2.5 py-2 outline-none placeholder:text-muted-foreground"
/>
{/* No tooltip on the start square — the ↵ glyph is the
+2 -2
View File
@@ -114,7 +114,7 @@ export function DraftShell({
// Tab navigates INTO the composer (owner, keyboard round 2026-10-06):
// the only text field on this surface — the cycle's whole chain.
const cycleComposer = useCallback((): readonly HTMLElement[] =>
Array.from(document.querySelectorAll<HTMLElement>('[data-testid="composer"] textarea')),
Array.from(document.querySelectorAll<HTMLElement>('[data-testid="composer"] [data-file-path-input]')),
[]);
useFieldCycle(cycleComposer);
const dictation = useDictation({
@@ -138,7 +138,7 @@ export function DraftShell({
};
/* Run end focuses the composer (owner, 2026-10-01): the hands are on
* the keyboard from the stop — falling edge of `active` only. */
const composerInputRef = useRef<HTMLTextAreaElement>(null);
const composerInputRef = useRef<HTMLDivElement>(null);
const dictationWasActiveRef = useRef(false);
useEffect(() => {
if (dictation.active) {
+245
View File
@@ -0,0 +1,245 @@
import { parseFilePaths, quoteFilePath } from "@/src/core";
/** Tokens store their complete source, not their abbreviated visual label. */
export const PATH_SOURCE_ATTRIBUTE = "data-file-path-source";
/** Presentation-only line box for the caret after a final model newline. */
export const PATH_CARET_ATTRIBUTE = "data-file-path-caret";
export type TextSelection = { anchor: number; focus: number };
export type DOMPoint = { node: Node; offset: number };
type Segment = { text: string; start: DOMPoint; end: DOMPoint; node?: Text; token?: Element };
function childIndex(node: Node): number {
return Array.prototype.indexOf.call(node.parentNode!.childNodes, node);
}
/** Browser editing can leave BR/block nodes; never use innerText (layout/whitespace dependent). */
function segments(root: HTMLElement): Segment[] {
const result: Segment[] = [];
function walk(node: Node) {
if (node.nodeType === 3) {
const text = node as Text;
result.push({ text: text.data, start: { node, offset: 0 }, end: { node, offset: text.length }, node: text });
return;
}
if (node.nodeType !== 1) return;
const element = node as Element;
if (element.hasAttribute(PATH_CARET_ATTRIBUTE)) return;
const raw = element.getAttribute(PATH_SOURCE_ATTRIBUTE);
if (raw !== null) {
const index = childIndex(node);
result.push({ text: raw, start: { node: node.parentNode!, offset: index }, end: { node: node.parentNode!, offset: index + 1 }, token: element });
return;
}
if (element.tagName === "BR") {
// Chromium's lone BR is an empty editor/paragraph placeholder.
if (node.parentNode?.childNodes.length === 1) return;
const index = childIndex(node);
result.push({ text: "\n", start: { node: node.parentNode!, offset: index }, end: { node: node.parentNode!, offset: index + 1 } });
return;
}
Array.from(node.childNodes).forEach((child, index) => {
const block = child.nodeType === 1 && /^(DIV|P)$/.test((child as Element).tagName);
const previous = node.childNodes[index - 1];
const previousBlock = previous?.nodeType === 1 && /^(DIV|P)$/.test((previous as Element).tagName);
if (index > 0 && (block || previousBlock)) {
result.push({ text: "\n", start: { node, offset: index }, end: { node, offset: index } });
}
walk(child);
});
}
walk(root);
return result;
}
export function readPathInput(root: HTMLElement): string {
return segments(root).map((part) => part.text).join("");
}
/** Map a logical UTF-16 offset to a DOM boundary, snapping a chip interior atomically. */
export function pathInputPoint(root: HTMLElement, offset: number): DOMPoint {
const parts = segments(root);
const trailing = root.lastChild;
if (trailing?.nodeType === 1 && (trailing as Element).hasAttribute(PATH_CARET_ATTRIBUTE)
&& offset >= parts.reduce((length, part) => length + part.text.length, 0)) {
// A text-node endpoint after its final newline has no line box in Chromium.
// The boundary immediately before this BR is on the visible final line.
return { node: root, offset: childIndex(trailing) };
}
let remaining = Math.max(0, offset);
for (const part of parts) {
if (remaining <= part.text.length) {
if (part.node) return { node: part.node, offset: remaining };
return remaining <= part.text.length / 2 ? part.start : part.end;
}
remaining -= part.text.length;
}
return { node: root, offset: root.childNodes.length };
}
/** Works without Range: useful in DOM test environments as well as real Selection. */
export function pathInputOffset(root: HTMLElement, node: Node, offset: number): number | null {
if (node !== root && !root.contains(node)) return null;
let total = 0;
for (const part of segments(root)) {
if (part.node === node) return total + Math.min(Math.max(0, offset), part.text.length);
if (part.token?.contains(node)) {
// A mouse/programmatic selection can end inside a noneditable label.
// Keep distinct interior endpoints so atomicPathRange expands copy/cut
// to the complete source instead of leaking the abbreviated filename.
let visualOffset = node.nodeType === 3 ? offset : Array.from(node.childNodes).slice(0, offset).reduce((length, child) => length + (child.textContent?.length ?? 0), 0);
let cursor = node;
while (cursor !== part.token) {
let sibling = cursor.previousSibling;
while (sibling) { visualOffset += sibling.textContent?.length ?? 0; sibling = sibling.previousSibling; }
cursor = cursor.parentNode!;
}
const visualLength = part.token.textContent?.length ?? 0;
return total + (visualLength ? Math.round(Math.min(visualLength, visualOffset) / visualLength * part.text.length) : 0);
}
if (part.start.node === node && part.start.offset >= offset) return total;
if (part.end.node === node && part.end.offset <= offset) {
total += part.text.length;
continue;
}
// A boundary in an ancestor is before/after every descendant segment.
let ancestor: Node = part.node ?? part.token ?? part.start.node;
while (ancestor.parentNode && ancestor.parentNode !== node) ancestor = ancestor.parentNode;
if (ancestor.parentNode === node && childIndex(ancestor) >= offset) return total;
total += part.text.length;
}
return total;
}
export function readPathSelection(root: HTMLElement): TextSelection | null {
const selection = root.ownerDocument.getSelection?.();
if (!selection?.anchorNode || !selection.focusNode) return null;
const anchor = pathInputOffset(root, selection.anchorNode, selection.anchorOffset);
const focus = pathInputOffset(root, selection.focusNode, selection.focusOffset);
return anchor === null || focus === null ? null : { anchor, focus };
}
export function restorePathSelection(root: HTMLElement, selection: TextSelection): void {
const browserSelection = root.ownerDocument.getSelection?.();
if (!browserSelection) return;
const anchor = pathInputPoint(root, selection.anchor);
const focus = pathInputPoint(root, selection.focus);
browserSelection.setBaseAndExtent(anchor.node, anchor.offset, focus.node, focus.offset);
}
/** Returns portal hosts. React owns only their visual contents, never the editable tree. */
export function renderPathInput(root: HTMLElement, value: string): Array<{ host: HTMLElement; path: string }> {
const document = root.ownerDocument;
const fragment = document.createDocumentFragment();
const chips: Array<{ host: HTMLElement; path: string }> = [];
for (const part of parseFilePaths(value)) {
if (part.kind === "text") {
fragment.append(document.createTextNode(part.text));
} else {
const host = document.createElement("span");
host.contentEditable = "false";
host.setAttribute("contenteditable", "false");
host.setAttribute(PATH_SOURCE_ATTRIBUTE, part.text);
fragment.append(host);
chips.push({ host, path: part.path });
}
}
// Empty boundary text nodes allow a caret immediately before/after an atomic chip.
if (!fragment.firstChild || fragment.firstChild.nodeType !== 3) fragment.prepend(document.createTextNode(""));
if (fragment.lastChild?.nodeType !== 3) fragment.append(document.createTextNode(""));
if (value.endsWith("\n")) {
// pre-wrap alone collapses the final empty line. This BR supplies its
// height/caret without adding a second newline to the stored source.
const caret = document.createElement("br");
caret.setAttribute(PATH_CARET_ATTRIBUTE, "");
caret.setAttribute("aria-hidden", "true");
fragment.append(caret);
}
root.replaceChildren(fragment);
root.dataset.empty = value.length === 0 ? "true" : "false";
return chips;
}
export function clampPathSelection(value: string, selection: TextSelection): TextSelection {
const clamp = (offset: number) => Math.min(value.length, Math.max(0, offset));
return { anchor: clamp(selection.anchor), focus: clamp(selection.focus) };
}
/** A selection intersecting any part of a chip edits/copies that complete source. */
export function atomicPathRange(value: string, selection: TextSelection): { start: number; end: number } {
const clamped = clampPathSelection(value, selection);
let start = Math.min(clamped.anchor, clamped.focus);
let end = Math.max(clamped.anchor, clamped.focus);
for (const part of parseFilePaths(value)) {
if (part.kind !== "path") continue;
if (start > part.start && start < part.end) start = part.start;
if (end > part.start && end < part.end) end = part.end;
}
return { start, end };
}
export function replacePathSelection(value: string, selection: TextSelection, text: string) {
const { start, end } = atomicPathRange(value, selection);
return { value: value.slice(0, start) + text + value.slice(end), selection: { anchor: start + text.length, focus: start + text.length } };
}
export function insertFilePaths(value: string, selection: TextSelection, paths: string[]) {
const { start, end } = atomicPathRange(value, selection);
const text = paths.map(quoteFilePath).join(" ");
if (!text) return { value, selection };
const before = start > 0 && !/\s/.test(value[start - 1]!) ? " " : "";
const after = end < value.length && !/\s/.test(value[end]!) ? " " : "";
return replacePathSelection(value, selection, before + text + after);
}
export function adjacentPathRange(value: string, selection: TextSelection, direction: "backward" | "forward"): TextSelection | null {
if (selection.anchor !== selection.focus) return selection;
const caret = selection.focus;
for (const part of parseFilePaths(value)) {
if (part.kind === "path" && (direction === "backward" ? caret > part.start && caret <= part.end : caret >= part.start && caret < part.end)) {
return { anchor: part.start, focus: part.end };
}
}
return null;
}
export type PathEditSnapshot = { value: string; selection: TextSelection };
/** Match the text input value sanitization algorithm: remove CR/LF, not spaces. */
export function normalizePathInputValue(value: string, singleLine: boolean): string {
return singleLine ? value.replace(/[\r\n]/g, "") : value;
}
export function normalizePathEdit(snapshot: PathEditSnapshot, singleLine: boolean): PathEditSnapshot {
if (!singleLine) return snapshot;
const selection = clampPathSelection(snapshot.value, snapshot.selection);
return {
value: normalizePathInputValue(snapshot.value, true),
selection: {
anchor: normalizePathInputValue(snapshot.value.slice(0, selection.anchor), true).length,
focus: normalizePathInputValue(snapshot.value.slice(0, selection.focus), true).length,
},
};
}
/** Independent of the browser's DOM undo stack (normalization replaces native nodes). */
export class PathInputHistory {
private undoStack: PathEditSnapshot[] = [];
private redoStack: PathEditSnapshot[] = [];
push(snapshot: PathEditSnapshot) {
this.undoStack.push({ value: snapshot.value, selection: { ...snapshot.selection } });
this.redoStack = [];
}
reset() { this.undoStack = []; this.redoStack = []; }
undo(current: PathEditSnapshot): PathEditSnapshot | null {
const previous = this.undoStack.pop();
if (!previous) return null;
this.redoStack.push(current);
return previous;
}
redo(current: PathEditSnapshot): PathEditSnapshot | null {
const next = this.redoStack.pop();
if (!next) return null;
this.undoStack.push(current);
return next;
}
}
+291
View File
@@ -0,0 +1,291 @@
"use client";
import * as React from "react";
import { createPortal } from "react-dom";
import { FilePathChip } from "./file-path-chip";
import { cn } from "../lib/utils";
import {
adjacentPathRange, atomicPathRange, clampPathSelection, insertFilePaths,
normalizePathEdit, normalizePathInputValue, PathInputHistory, readPathInput, readPathSelection, renderPathInput,
replacePathSelection, restorePathSelection,
type PathEditSnapshot, type TextSelection,
} from "./path-input-dom";
export const INSERT_FILE_PATHS_EVENT = "lh:insert-file-paths";
export type PathInputProps = Omit<React.HTMLAttributes<HTMLDivElement>, "children" | "dangerouslySetInnerHTML" | "defaultValue" | "onChange"> & {
value: string;
onValueChange: (value: string) => void;
placeholder?: string;
autoFocus?: boolean;
defaultDropTarget?: boolean;
/** Native text-input semantics: strip CR/LF and leave Enter to the caller. */
singleLine?: boolean;
};
type ChipHost = { host: HTMLElement; path: string };
type Controller = {
external: (value: string) => void;
remember: () => void;
};
/**
* A controlled plaintext editor. Only the source string is state; chips are atomic
* views of it. React never reconciles the editable DOM after a native mutation.
*/
export const PathInput = React.forwardRef<HTMLDivElement, PathInputProps>(function PathInput({
value, onValueChange, placeholder, autoFocus, defaultDropTarget = false, singleLine = false, className,
onKeyDown, onFocus, onBlur, onInput, onPaste, onCopy, onCut,
onCompositionStart, onCompositionEnd, ...props
}, forwardedRef) {
const rootRef = React.useRef<HTMLDivElement>(null);
const controllerRef = React.useRef<Controller | null>(null);
const callbacks = React.useRef({ onValueChange, singleLine });
const composing = React.useRef(false);
const [chips, setChips] = React.useState<ChipHost[]>([]);
// Keep this text child immutable: SSR/hydration gets the complete draft,
// while subsequent React renders leave the browser-owned editable DOM alone.
const initial = React.useRef({ value: normalizePathInputValue(value, singleLine), autoFocus, singleLine });
React.useImperativeHandle(forwardedRef, () => rootRef.current!, []);
React.useLayoutEffect(() => { callbacks.current = { onValueChange, singleLine }; }, [onValueChange, singleLine]);
React.useLayoutEffect(() => {
const root = rootRef.current!;
const document = root.ownerDocument;
let current = normalizePathInputValue(initial.current.value, initial.current.singleLine);
let saved: TextSelection = { anchor: current.length, focus: current.length };
let beforeEdit: PathEditSnapshot | null = null;
let compositionStart: PathEditSnapshot | null = null;
let pendingExternal: string | null = null;
let pendingPaths: string[] = [];
let pendingPaste: string[] = [];
let mounted = true;
const history = new PathInputHistory();
function remember() {
if (document.activeElement !== root) return;
const selection = readPathSelection(root);
if (selection) saved = selection;
}
function snapshot(): PathEditSnapshot {
return { value: current, selection: { ...saved } };
}
function paint(selection: TextSelection, restore: boolean) {
setChips(renderPathInput(root, current));
saved = clampPathSelection(current, selection);
if (restore) restorePathSelection(root, saved);
}
function change(next: PathEditSnapshot, record = true, before = snapshot(), notify = true) {
next = normalizePathEdit(next, callbacks.current.singleLine);
if (next.value === current) { paint(next.selection, document.activeElement === root); return; }
if (record) history.push(before);
current = next.value;
paint(next.selection, document.activeElement === root);
if (notify) callbacks.current.onValueChange(current);
}
function replace(text: string) {
remember();
change(replacePathSelection(current, saved, text));
}
function travel(direction: "undo" | "redo") {
remember();
const next = history[direction](snapshot());
if (next) change(next, false);
}
function stage(paths: string[]) {
if (composing.current) { pendingPaths.push(...paths); return; }
remember();
// Global drop may have moved focus: saved selection is still authoritative.
let next: PathEditSnapshot;
try { next = insertFilePaths(current, saved, paths); }
catch { return; } // Invalid event payloads never mutate learner text.
change(next);
root.focus();
restorePathSelection(root, saved);
}
function insertEvent(event: Event) {
const paths: unknown = (event as CustomEvent<unknown>).detail;
if (Array.isArray(paths) && paths.every((path) => typeof path === "string")) stage(paths);
}
function beforeInput(event: InputEvent) {
if (composing.current || event.isComposing) return;
remember();
beforeEdit = snapshot();
if (event.inputType === "historyUndo" || event.inputType === "historyRedo") {
event.preventDefault();
travel(event.inputType === "historyUndo" ? "undo" : "redo");
} else if (event.inputType === "insertParagraph" || event.inputType === "insertLineBreak") {
event.preventDefault();
if (!callbacks.current.singleLine) replace("\n");
} else if (event.inputType.startsWith("format") || event.inputType === "insertHTML") {
event.preventDefault();
} else if (event.inputType === "deleteByCut") {
// The cut handler owns both the full-source clipboard and deletion.
// Never perform a second deletion if a browser also sends beforeinput.
event.preventDefault();
} else if (event.inputType.startsWith("delete")) {
const range = adjacentPathRange(current, saved, event.inputType.includes("Backward") ? "backward" : "forward");
if (range) {
event.preventDefault();
change(replacePathSelection(current, range, ""));
}
}
}
function input() {
if (composing.current) {
root.dataset.empty = normalizePathInputValue(readPathInput(root), callbacks.current.singleLine).length === 0 ? "true" : "false";
return;
}
const nextValue = readPathInput(root);
const selection = readPathSelection(root) ?? saved;
const previous = beforeEdit ?? snapshot();
beforeEdit = null;
change({ value: nextValue, selection }, true, previous);
}
function keyDown(event: KeyboardEvent) {
if (composing.current || event.isComposing || event.keyCode === 229) return;
// Shift+Delete is native Cut on Windows/Linux, not forward deletion.
// Let the browser dispatch cut so our clipboard handler can own it.
if (event.key === "Delete" && event.shiftKey && !event.ctrlKey && !event.metaKey && !event.altKey) return;
remember();
if ((event.metaKey || event.ctrlKey) && !event.altKey && (event.key.toLowerCase() === "z" || event.key.toLowerCase() === "y")) {
event.preventDefault();
travel(event.key.toLowerCase() === "y" || event.shiftKey ? "redo" : "undo");
} else if (event.key === "Backspace" || event.key === "Delete") {
const range = adjacentPathRange(current, saved, event.key === "Backspace" ? "backward" : "forward");
if (range) {
event.preventDefault();
change(replacePathSelection(current, range, ""));
}
}
}
function paste(event: ClipboardEvent) {
if (event.defaultPrevented) return;
event.preventDefault();
const text = event.clipboardData?.getData("text/plain") ?? "";
// Image/file/HTML-only clipboard contents are not text replacement.
if (text === "") return;
if (composing.current) pendingPaste.push(text);
else replace(text);
}
function copy(event: ClipboardEvent) {
if (event.defaultPrevented) return;
remember();
if (saved.anchor === saved.focus || !event.clipboardData) return;
const { start, end } = atomicPathRange(current, saved);
event.clipboardData.setData("text/plain", current.slice(start, end));
event.preventDefault();
if (event.type === "cut" && !composing.current) change(replacePathSelection(current, saved, ""));
}
function startComposition() {
remember();
composing.current = true;
compositionStart = snapshot();
}
function endComposition() {
// Some browsers send the final input immediately AFTER compositionend.
queueMicrotask(() => {
if (!mounted) return;
let nextValue = readPathInput(root);
let selection = readPathSelection(root) ?? saved;
const previous = compositionStart ?? snapshot();
const reset = pendingExternal !== null && !pendingExternal.startsWith(previous.value);
const nativeChanged = normalizePathInputValue(nextValue, callbacks.current.singleLine) !== current;
const notify = pendingExternal === null || (!reset && nativeChanged);
if (pendingExternal !== null) {
if (pendingExternal.startsWith(previous.value)) {
nextValue += pendingExternal.slice(previous.value.length);
} else {
nextValue = pendingExternal;
selection = clampPathSelection(nextValue, selection);
}
}
pendingExternal = null;
compositionStart = null;
beforeEdit = null;
composing.current = false;
if (reset) history.reset();
change({ value: nextValue, selection }, !reset, previous, notify);
if (pendingPaste.length) { const text = pendingPaste.join(""); pendingPaste = []; replace(text); }
if (pendingPaths.length) { const paths = pendingPaths; pendingPaths = []; stage(paths); }
});
}
controllerRef.current = {
remember,
external(next) {
next = normalizePathInputValue(next, callbacks.current.singleLine);
if (composing.current) { pendingExternal = next; return; }
if (next === current) return;
remember();
const previous = snapshot();
// Dictation appends are undoable; a controlled reset starts fresh history.
if (next && next.startsWith(current)) history.push(previous);
else history.reset();
const selection = saved.anchor === current.length && saved.focus === current.length
? { anchor: next.length, focus: next.length } : clampPathSelection(next, saved);
current = next;
paint(selection, document.activeElement === root);
},
};
paint(saved, false);
if (initial.current.autoFocus) { root.focus(); restorePathSelection(root, saved); }
root.addEventListener(INSERT_FILE_PATHS_EVENT, insertEvent);
root.addEventListener("beforeinput", beforeInput);
root.addEventListener("input", input);
root.addEventListener("keydown", keyDown);
root.addEventListener("paste", paste);
root.addEventListener("copy", copy);
root.addEventListener("cut", copy);
root.addEventListener("compositionstart", startComposition);
root.addEventListener("compositionend", endComposition);
document.addEventListener("selectionchange", remember);
return () => {
mounted = false;
composing.current = false;
controllerRef.current = null;
root.removeEventListener(INSERT_FILE_PATHS_EVENT, insertEvent);
root.removeEventListener("beforeinput", beforeInput);
root.removeEventListener("input", input);
root.removeEventListener("keydown", keyDown);
root.removeEventListener("paste", paste);
root.removeEventListener("copy", copy);
root.removeEventListener("cut", copy);
root.removeEventListener("compositionstart", startComposition);
root.removeEventListener("compositionend", endComposition);
document.removeEventListener("selectionchange", remember);
};
}, []);
React.useLayoutEffect(() => { controllerRef.current?.external(value); }, [value, singleLine]);
return <>
<div
{...props}
ref={rootRef}
role="textbox"
aria-label={props["aria-label"] ?? placeholder}
aria-multiline={singleLine ? "false" : "true"}
aria-placeholder={placeholder}
contentEditable
suppressContentEditableWarning
data-file-path-input=""
data-empty={initial.current.value.length === 0 ? "true" : "false"}
data-file-path-default={defaultDropTarget ? "" : undefined}
data-placeholder={placeholder}
className={cn("block w-full min-w-0 outline-none data-[empty=true]:before:pointer-events-none data-[empty=true]:before:text-muted-foreground data-[empty=true]:before:content-[attr(data-placeholder)]", singleLine ? "whitespace-pre break-normal overflow-x-auto overflow-y-hidden" : "whitespace-pre-wrap break-words overflow-y-auto", className)}
onKeyDown={(event) => {
if (composing.current || event.nativeEvent.isComposing || event.keyCode === 229) { event.stopPropagation(); return; }
onKeyDown?.(event);
}}
onFocus={(event) => { controllerRef.current?.remember(); onFocus?.(event); }}
onBlur={(event) => { controllerRef.current?.remember(); onBlur?.(event); }}
onInput={onInput}
onPaste={onPaste}
onCopy={onCopy}
onCut={onCut}
onCompositionStart={onCompositionStart}
onCompositionEnd={onCompositionEnd}
>{initial.current.value}</div>
{chips.map(({ host, path }) => createPortal(<FilePathChip path={path} contentEditable={false} />, host))}
</>;
});
+5 -5
View File
@@ -6,6 +6,7 @@ import { ToolContentResponse } from "@/components/ai-elements/message";
import { Button } from "@/components/ui/button";
import { joinDictation, useDictationTarget } from "@/components/dictation";
import { useEnterSubmits } from "@/components/enter-submits";
import { PathInput } from "@/components/path-input";
import { cn } from "@/lib/utils";
/**
@@ -121,11 +122,11 @@ export function PracticeDock({ problemCallId, input, expanded, onExpand, onSubmi
<div className="px-2.5 pt-2 text-xs font-medium uppercase tracking-wide text-muted-foreground">
YOUR THINKING
</div>
<textarea
<PathInput
value={thinking}
onChange={(e) => {
thinkingRef.current = e.target.value;
setThinking(e.target.value);
onValueChange={(value) => {
thinkingRef.current = value;
setThinking(value);
}}
onKeyDown={(e) => {
// Esc ends dictation — stopPropagation claims it (the
@@ -144,7 +145,6 @@ export function PracticeDock({ problemCallId, input, expanded, onExpand, onSubmi
}
}}
placeholder="Work here, think out loud."
rows={4}
className={cn(
"field-sizing-content lh-input max-h-64 min-h-16 w-full resize-none bg-transparent px-2.5 pb-2 outline-none placeholder:text-muted-foreground",
)}
+16 -16
View File
@@ -5,6 +5,7 @@ import { ArrowDownIcon, ChevronRightIcon, PlayIcon } from "lucide-react";
import { Button } from "@/components/ui/button";
import { finishDictation, isDictationActive, joinDictation, useDictationTarget } from "@/components/dictation";
import { useEnterSubmits } from "@/components/enter-submits";
import { PathInput } from "@/components/path-input";
import { ToolContentResponse } from "@/components/ai-elements/message";
import { cn } from "@/lib/utils";
import {
@@ -439,8 +440,8 @@ function QuestionDockForm({
expanded — collapsed, the bar's native Enter expands it. */
const [kbdIdx, setKbdIdx] = useState<number | null>(null);
const rootRef = useRef<HTMLDivElement>(null);
const otherInputRef = useRef<HTMLInputElement>(null);
const thinkingRef = useRef<HTMLTextAreaElement>(null);
const otherInputRef = useRef<HTMLDivElement>(null);
const thinkingRef = useRef<HTMLDivElement>(null);
// One row past the options is the trailing row: quiz → don't-know,
// ask → Other (a question never has both). Text mode (ask/explain)
// has no rows — the composer IS the answer surface.
@@ -473,7 +474,7 @@ function QuestionDockForm({
const focusRow = (idx: number) => {
const row = rootRef.current?.querySelector(`[data-kbdrow="${idx}"]`) as HTMLElement | null;
if (!row) return;
const el = (row.querySelector("input") as HTMLElement | null) ?? row;
const el = (row.querySelector("input, [data-file-path-input]") as HTMLElement | null) ?? row;
el.focus();
};
@@ -696,11 +697,12 @@ function QuestionDockForm({
</span>
)}
</span>
<input
<PathInput
ref={otherInputRef}
singleLine
value={other}
onChange={(e) => {
setOther(e.target.value);
onValueChange={(value) => {
setOther(value);
// Typing selects the row — displacing in
// single-select, alongside the siblings in multi.
setSel((prev) => (prev.otherSelected ? prev : toggleOther(selectMode, prev)));
@@ -851,11 +853,11 @@ function QuestionDockForm({
{qd.mode === "text" ? "Your answer" : "YOUR THINKING"}
</div>
{qd.mode === "text" ? (
<textarea
<PathInput
value={text}
onChange={(e) => {
answerTextRef.current = e.target.value;
setText(e.target.value);
onValueChange={(value) => {
answerTextRef.current = value;
setText(value);
}}
onKeyDown={(e) => {
// Esc ends dictation (the dictate decisions: click-again
@@ -875,16 +877,15 @@ function QuestionDockForm({
}
}}
placeholder="Type your answer…"
rows={1}
className="field-sizing-content lh-input max-h-48 min-h-10 w-full resize-none bg-transparent px-2.5 py-2 outline-none placeholder:text-muted-foreground"
/>
) : (
<textarea
<PathInput
ref={thinkingRef}
value={thinking}
onChange={(e) => {
thinkingTextRef.current = e.target.value;
setThinking(e.target.value);
onValueChange={(value) => {
thinkingTextRef.current = value;
setThinking(value);
}}
onKeyDown={(e) => {
// Esc ends dictation (the dictate decisions: click-again
@@ -905,7 +906,6 @@ function QuestionDockForm({
}
}}
placeholder="Why this answer? Whatever comes to mind."
rows={1}
className={cn(
"field-sizing-content lh-input max-h-48 w-full resize-none bg-transparent px-2.5 py-2 outline-none placeholder:text-muted-foreground/70",
// Phase-scaled prominence: teaching invites depth,
+3 -3
View File
@@ -2,6 +2,7 @@
import { useCallback } from "react";
import { joinDictation, useDictationTarget } from "@/components/dictation";
import { PathInput } from "@/components/path-input";
/**
* The reading-thinking field (owner design 2026-10-03, "two questions per
@@ -54,9 +55,9 @@ export function ReadingThinkingField({
<div className="pb-1 text-xs font-medium uppercase tracking-wide text-muted-foreground">
Thinking while reading
</div>
<textarea
<PathInput
value={value}
onChange={(e) => onChange(e.target.value)}
onValueChange={onChange}
/* py-0.5 (owner round, 2026-10-07): the scrollbar fix. Source
Sans 3's font box is 22px at 16px; lh-input's 1.25 leading
gives a 20px line box — the content area sticks 1px below the
@@ -95,7 +96,6 @@ export function ReadingThinkingField({
}
}}
placeholder="Whatever comes to mind as you read."
rows={1}
className="field-sizing-content lh-input max-h-40 w-full resize-none bg-transparent py-0.5 outline-none placeholder:text-muted-foreground/70"
/>
</div>
+1 -1
View File
@@ -4,7 +4,7 @@ import { findSessionTextMatches } from "@/src/core";
* hidden/unmounted sections do not. Never walk embedded documents or drafts.
* Do NOT exclude aria-hidden globally: KaTeX's VISIBLE HTML uses it too. */
const EXCLUDED = [
"script", "style", "noscript", "textarea", "input", "select", "iframe", "canvas",
"script", "style", "noscript", "textarea", "input", "select", "iframe", "canvas", '[contenteditable="true"]',
"title", "defs", "metadata", "desc", ".katex-mathml", ".sr-only", "[hidden]",
"[data-session-find-exclude]", "[data-lh-menu]", '[role="menu"]', '[role="dialog"]', '[role="tooltip"]',
].join(",");
+4 -1
View File
@@ -50,6 +50,7 @@ const STORAGE_KEY = "lh:sidebar-open";
*/
import type { ChatGPTControlBridge, NativeSubscriptionControlBridge } from "@/src/core";
import type { UpdateState } from "@/components/settings/update-settings.tsx";
import { FilePathDropZone } from "@/components/file-path-drop-zone";
const SIDEBAR_AUTOCLOSE = 928;
// Module scope, guarded for SSR (the server has no navigator).
@@ -61,6 +62,8 @@ declare global {
lh?: {
/** Read-only host OS; determines the native-control gutter. */
platform?: string;
/** Resolve an OS-backed File without copying/uploading its contents. */
getPathForFile?: (file: File) => string;
/** External Next desktop-dev uses the server's web connections. */
subscriptionTransport?: "desktop" | "web";
openSubscriptionBrowser?: (action: string) => Promise<void>;
@@ -944,7 +947,7 @@ export function AppShell({ children }: { children: React.ReactNode }) {
return (
<SettingsDialogProvider>
<SessionsSidebar />
<main className="min-w-0 flex-1">{children}</main>
<FilePathDropZone className="relative min-w-0 flex-1">{children}</FilePathDropZone>
{/* The single document-level keydown listener (keyboard wave A) —
after the content in the tree, but it's window-level so order only
matters for the events it dispatches, never for DOM priority. */}
+2 -1
View File
@@ -4,6 +4,7 @@ import { useEffect, useRef, useState } from "react";
import { ChevronDownIcon } from "lucide-react";
import { blurAfterPointerClick } from "@/components/focus-hygiene";
import { cn } from "@/lib/utils";
import { PathText } from "@/components/file-path-chip";
/** The unified user-thinking row (owner round, 2026-10-05): ONE visual
* language for every surface where the learner's own thinking renders —
@@ -127,7 +128,7 @@ export function ThinkingBlock({
<ChevronDownIcon className={cn("size-4 shrink-0 transition-transform", open ? "rotate-180" : "rotate-0")} />
</button>
{open && (
<span className="lh-prose mt-1.5 block whitespace-pre-wrap text-[15px] italic text-foreground/50">{text}</span>
<span className="lh-prose mt-1.5 block whitespace-pre-wrap text-[15px] italic text-foreground/50"><PathText text={text} /></span>
)}
</div>
);