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>
);
+4 -1
View File
@@ -29,7 +29,7 @@
*
* Channel names are mirrored in electron/main.cjs — keep them in sync.
*/
const { contextBridge, ipcRenderer } = require("electron");
const { contextBridge, ipcRenderer, webUtils } = require("electron");
let openSettingsListener = null;
let pendingOpenSettings = null;
@@ -40,6 +40,9 @@ ipcRenderer.on("lh:open-settings", (_event, section) => {
contextBridge.exposeInMainWorld("lh", {
platform: process.platform,
// Resolve only a renderer-supplied, OS-backed File. No filesystem API,
// uploads, copies, or DOM File IPC to main; sandbox/isolation stay on.
getPathForFile: (file) => webUtils.getPathForFile(file),
...(process.argv?.includes("--lh-web-subscriptions") ? {
subscriptionTransport: "web",
openSubscriptionBrowser: (action) => ipcRenderer.invoke("lh:subscriptions:web-browser", action),
+424
View File
@@ -0,0 +1,424 @@
/** Offline synthetic UI + actual Electron preload smoke. No providers/accounts.
* Run: node scripts/smoke-file-paths.mjs
* App/profile/auth/OS-backed fixture files are temporary; only this script's
* processes are stopped. Browser requests outside the owned origin are blocked.
*/
import assert from "node:assert/strict";
import { spawn } from "node:child_process";
import { once } from "node:events";
import fs from "node:fs";
import net from "node:net";
import { createRequire } from "node:module";
import os from "node:os";
import path from "node:path";
import puppeteer from "puppeteer-core";
const root = path.resolve(import.meta.dirname, "..");
const home = fs.mkdtempSync(path.join(os.tmpdir(), "lh-file-paths-"));
const require = createRequire(import.meta.url);
const checks = [];
const errors = [];
const visualFailures = [];
const coverageFailures = [];
const posts = [];
const blocked = [];
const networkFailures = [];
const consoleErrors = [];
const children = [];
const browsers = [];
const screenshots = [];
let serverLogs = "";
let electronLogs = "";
let page;
let origin;
let shuttingDown = false;
const fixtureCatalog = (() => {
const model = { id: "synthetic", name: "Synthetic (no calls)", provider: "fixture", input: ["text"], reasoning: false, supportedLevels: ["off"], cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, contextWindow: 8192, maxTokens: 1024 };
const slot = { model: { provider: model.provider, id: model.id }, thinkingLevel: "off" };
return { models: [model], providers: [{ id: "fixture", label: "Fixture", keyPresent: true }], defaults: null, slots: { strong: slot, cheap: slot, vision: slot }, rolePins: {} };
})();
async function freePort() {
const listener = net.createServer();
listener.listen(0, "127.0.0.1");
await once(listener, "listening");
const port = listener.address().port;
await new Promise(resolve => listener.close(resolve));
return port;
}
async function stop(child) {
if (!child || child.exitCode !== null || child.signalCode !== null) return;
const exited = once(child, "exit");
child.kill("SIGTERM");
const timer = setTimeout(() => child.kill("SIGKILL"), 5000); timer.unref();
await exited; clearTimeout(timer);
}
async function startup(child, getLogs, marker, timeout = 90000) {
await new Promise((resolve, reject) => {
const timer = setTimeout(() => finish(new Error(`Startup timed out: ${getLogs()}`)), timeout);
const inspect = () => { if (marker.test(getLogs())) finish(); };
const exited = code => finish(new Error(`Process exited ${code}: ${getLogs()}`));
function finish(error) {
clearTimeout(timer); child.stdout.off("data", inspect); child.stderr.off("data", inspect); child.off("exit", exited);
if (error) reject(error); else resolve();
}
child.stdout.on("data", inspect); child.stderr.on("data", inspect); child.once("exit", exited); inspect();
});
}
async function instrument(target) {
target.setDefaultTimeout(20000);
target.on("pageerror", error => { if (!shuttingDown) errors.push(error.message); });
target.on("requestfailed", request => { if (!shuttingDown) networkFailures.push({ url: request.url(), error: request.failure()?.errorText }); });
target.on("console", message => { if (message.type() === "error") consoleErrors.push(message.text()); });
await target.setRequestInterception(true);
target.on("request", async request => {
try {
const url = new URL(request.url());
if (url.protocol === "data:" || url.protocol === "about:") return await request.continue();
if (url.origin !== origin) { blocked.push(request.url()); return await request.abort(); }
const respond = body => request.respond({ status: 200, contentType: "application/json", body: JSON.stringify(body) });
if (url.pathname === "/api/models") return await respond(fixtureCatalog);
if (url.pathname === "/api/events") return await request.respond({ status: 204 });
// Never forward agent, answer or import requests, even if a UI bug
// submits while dropping. Record their complete synthetic bodies.
if (request.method() === "POST" && (/^\/api\/(chat|answer)(\/|$)/.test(url.pathname) || /\/(answer|import|turn|retry)(\/|$)/.test(url.pathname))) {
posts.push({ pathname: url.pathname, body: JSON.parse(request.postData() || "{}") });
return await request.respond({ status: 503, contentType: "application/json", body: JSON.stringify({ code: "models_not_configured", missing: ["strong"] }) });
}
if (/^\/api\/chats\/[^/]+\/stream$/.test(url.pathname)) return await request.respond({ status: 204 });
return await request.continue();
} catch (error) {
if (!shuttingDown && !/Target closed|Session closed|Invalid InterceptionId|already handled/.test(String(error))) errors.push(String(error));
}
});
}
const inputSelector = '[data-file-path-input]';
async function open(target, route) {
await target.goto(origin + route, { waitUntil: "domcontentloaded", timeout: 120000 });
await target.waitForSelector(inputSelector);
await target.waitForFunction(() => !document.querySelector('[data-testid="setup-cta"], [data-testid="setup-cta-keys"]'));
await target.waitForFunction(() => document.querySelector('[data-testid="file-path-drop-zone"]'));
}
async function read(target, selector = inputSelector) {
return target.$eval(selector, element => [...element.childNodes].map(node => node.nodeType === 1 ? node.getAttribute("data-file-path-source") ?? node.textContent : node.textContent).join(""));
}
async function expectValue(target, expected, selector = inputSelector) {
await target.waitForFunction((selector, expected) => {
const element = document.querySelector(selector);
return element && [...element.childNodes].map(node => node.nodeType === 1 ? node.getAttribute("data-file-path-source") ?? node.textContent : node.textContent).join("") === expected;
}, {}, selector, expected);
}
async function fill(target, value, selector = inputSelector) {
await target.$eval(selector, (element, value) => {
element.focus(); element.textContent = value;
const selection = document.getSelection(); selection.collapse(element, element.childNodes.length);
element.dispatchEvent(new InputEvent("input", { bubbles: true, inputType: "insertText" }));
}, value);
await expectValue(target, value, selector);
}
async function caret(target, offset, selector = inputSelector) {
await target.$eval(selector, (element, offset) => {
element.focus(); let rest = offset;
for (const node of element.childNodes) {
const source = node.nodeType === 1 ? node.getAttribute("data-file-path-source") : null;
const length = source?.length ?? node.textContent.length;
if (rest <= length) {
if (source !== null) {
const index = [...element.childNodes].indexOf(node);
document.getSelection().collapse(element, index + (rest > 0 ? 1 : 0));
} else document.getSelection().collapse(node, rest);
document.dispatchEvent(new Event("selectionchange")); return;
}
rest -= length;
}
document.getSelection().collapse(element, element.childNodes.length);
document.dispatchEvent(new Event("selectionchange"));
}, offset);
}
async function shortcut(target, key, shift = false) {
await target.keyboard.down("Meta"); if (shift) await target.keyboard.down("Shift");
await target.keyboard.press(key);
if (shift) await target.keyboard.up("Shift"); await target.keyboard.up("Meta");
}
async function clipboardCommand(target, command, key, modifiers = 4) {
// Headless Chromium does not run the macOS menu accelerator for Meta+C/V.
// CDP's native edit command triggers the real browser clipboard event.
const session = await target.createCDPSession();
try { await session.send("Input.dispatchKeyEvent", { type: "rawKeyDown", key: key ?? (command === "copy" ? "c" : "v"), modifiers, commands: [command] }); }
finally { await session.detach(); }
}
async function syntheticDrop(target, paths, selector = '[data-testid="file-path-drop-zone"]') {
return target.$eval(selector, (zone, paths) => {
const transfer = new DataTransfer();
window.__syntheticPaths = new WeakMap();
window.lh = { platform: "darwin", getPathForFile(file) { return window.__syntheticPaths.get(file) ?? ""; } };
for (const [index, path] of paths.entries()) {
const file = new File(["never read"], `synthetic-display-${index}.pdf`, { type: "application/pdf" });
window.__syntheticPaths.set(file, path);
for (const method of ["text", "arrayBuffer", "stream"]) Object.defineProperty(file, method, { value() { throw new Error("File bytes must not be read"); } });
transfer.items.add(file);
}
const event = new DragEvent("drop", { bubbles: true, cancelable: true, dataTransfer: transfer });
zone.dispatchEvent(event); return event.defaultPrevented;
}, paths);
}
async function blur(target) { await target.evaluate(() => { document.activeElement?.blur(); }); }
function seedChatFixtures() {
const usage = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, totalTokens: 0, cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 } };
const transcript = Array.from({ length: 100 }, (_, index) => `Synthetic lesson paragraph ${index}: one and one make two. These are offline fixture notes.`).join("\n\n");
const assistant = (id, content) => ({ id, role: "assistant", api: "anthropic-messages", provider: "anthropic", model: "synthetic", timestamp: 1, stopReason: "toolUse", usage, content });
const call = (id, name, args) => ({ type: "toolCall", id, name, arguments: args });
const result = (id, toolCallId, toolName, details) => ({ id, role: "toolResult", toolCallId, toolName, content: [{ type: "text", text: "Synthetic tool completed." }], ...(details ? { details } : {}), isError: false, timestamp: 2 });
const quiz = { type: "check", nodeId: "addition", question: "What is 1 + 1?", options: [{ value: "two", label: "2" }, { value: "three", label: "3" }], explanation: "Adding one to one gives two.", correctAnswer: "two" };
const records = [
{ id: "path_teach", messages: [assistant("m_probe", [{ type: "text", text: transcript }, call("call_probe", "probe_done", { assessment: { strands: [{ strand: "Addition", assessment: "Ready for a check." }] } })]), result("m_probe_result", "call_probe", "probe_done", { ok: true }), assistant("m_quiz", [call("call_quiz", "quiz", quiz)])] },
{ id: "path_probe", messages: [assistant("m_probe_question", [{ type: "text", text: transcript }, call("call_probe_question", "probe", quiz)])] },
{ id: "path_practice", kind: "practice", messages: [assistant("m_problem", [{ type: "text", text: transcript }, call("call_problem", "problem", { statement: "Compute 1 + 1. Explain your reasoning.", mode: "recall", modelSolution: "1 + 1 = 2." })]), result("m_problem_result", "call_problem", "problem")] },
];
for (const record of records) {
record.messages.unshift({ id: "sent_user", role: "user", content: "Attached `/synthetic/transcript notes.pdf`", timestamp: 0 });
fs.writeFileSync(path.join(home, "data", "chats", `${record.id}.json`), JSON.stringify({ version: 1, title: `Synthetic ${record.id}`, createdAt: "2026-10-28T00:00:00.000Z", updatedAt: "2026-10-28T00:00:00.000Z", ...record }));
}
}
async function foldDock(target, barSelector) {
// Let growth/geometry muting settle, then use a real wheel gesture so
// stick-to-bottom records user intent instead of a programmatic clamp.
// The explicit reveal owns a bounded 2s fold-suppression window.
await target.evaluate(() => new Promise(resolve => setTimeout(resolve, 2250)));
const log = await target.$('[role="log"]'); const box = await log.boundingBox();
await target.mouse.move(box.x + box.width / 2, box.y + 100);
// Multiple real wheel frames let stick-to-bottom publish isAtBottom
// before DockOverlay evaluates the next away-from-bottom scroll event.
for (const deltaY of [-420, -420, -840]) {
await target.mouse.wheel({ deltaY });
await target.evaluate(() => new Promise(resolve => setTimeout(resolve, 100)));
}
try { await target.waitForSelector(barSelector, { visible: true, timeout: 5000 }); return true; }
catch {
coverageFailures.push({ url: target.url(), case: barSelector, message: "Native wheel did not expose collapsed bar", scrollers: await target.evaluate(() => [...document.querySelectorAll('[role="log"], [role="log"] *')].filter(element => element.scrollHeight > element.clientHeight + 100 && getComputedStyle(element).overflowY !== "visible").map(element => ({ tag: element.tagName, className: element.className, scrollTop: element.scrollTop, scrollHeight: element.scrollHeight, clientHeight: element.clientHeight }))) });
return false;
}
}
try {
fs.mkdirSync(path.join(home, "data", "chats"), { recursive: true });
fs.writeFileSync(path.join(home, "auth.json"), "{}\n");
seedChatFixtures();
const { getFontAxes } = require("next/dist/compiled/@next/font/dist/google/get-font-axes.js");
const { getGoogleFontsUrl } = require("next/dist/compiled/@next/font/dist/google/get-google-fonts-url.js");
const fonts = Object.fromEntries(["JetBrains Mono", "Source Sans 3"].map(family => [getGoogleFontsUrl(family, getFontAxes(family, ["variable"], ["normal"]), "swap"), `@font-face { font-family: '${family}'; src: local('Arial'); font-weight: 100 900; }`]));
fs.writeFileSync(path.join(home, "fonts.cjs"), `module.exports = ${JSON.stringify(fonts)};\n`);
const port = await freePort(); origin = `http://127.0.0.1:${port}`;
const env = { PATH: process.env.PATH, HOME: home, TMPDIR: home, NODE_ENV: "development", LH_DATA_DIR: path.join(home, "data"), LH_AUTH_FILE: path.join(home, "auth.json"), LH_IGNORE_ENV_KEYS: "1", NEXT_TELEMETRY_DISABLED: "1", NEXT_FONT_GOOGLE_MOCKED_RESPONSES: path.join(home, "fonts.cjs") };
const server = spawn(process.execPath, ["node_modules/next/dist/bin/next", "dev", "--hostname", "127.0.0.1", "--port", String(port)], { cwd: root, env, stdio: ["ignore", "pipe", "pipe"] }); children.push(server);
const captureServer = data => { serverLogs = (serverLogs + data).slice(-28000); };
server.stdout.on("data", captureServer); server.stderr.on("data", captureServer);
await startup(server, () => serverLogs, /Ready in/);
if (!process.argv.includes("--desktop-only")) {
const chrome = await puppeteer.launch({ executablePath: process.env.LH_TEST_CHROME || "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome", headless: true, userDataDir: path.join(home, "chrome-profile"), env, args: ["--disable-background-networking", "--disable-sync", "--no-first-run", "--disable-default-apps"] }); browsers.push(chrome);
page = await chrome.newPage(); await page.setViewport({ width: 1280, height: 900 }); await instrument(page);
await chrome.defaultBrowserContext().overridePermissions(origin, ["clipboard-read", "clipboard-write", "clipboard-sanitized-write"]);
const token = "`/synthetic/course notes.pdf`";
for (const route of ["/", "/chat/new"]) {
await open(page, route);
// Sidebar drops prevent browser navigation but must not stage paths or
// activate the right-content attachment indicator.
assert.equal(await page.$eval("aside", sidebar => {
const transfer = new DataTransfer(); transfer.items.add(new File(["not uploaded"], "sidebar.pdf"));
const event = new DragEvent("drop", { bubbles: true, cancelable: true, dataTransfer: transfer }); sidebar.dispatchEvent(event); return event.defaultPrevented;
}), true);
assert.equal(await page.$('[data-testid="file-path-drop-indicator"]'), null);
await expectValue(page, "");
// Browser mode has no bridge: prevent file navigation with a clear notice.
const prevented = await page.$eval('[data-testid="file-path-drop-zone"]', zone => {
const transfer = new DataTransfer(); transfer.items.add(new File(["not uploaded"], "notes.pdf"));
const event = new DragEvent("drop", { bubbles: true, cancelable: true, dataTransfer: transfer }); zone.dispatchEvent(event); return event.defaultPrevented;
});
assert.equal(prevented, true);
await page.waitForFunction(() => document.querySelector('[role="status"]')?.textContent.includes("desktop app"));
await fill(page, "read "); await page.keyboard.type(token + " now");
await expectValue(page, `read ${token} now`);
assert.equal(await page.$$eval('[data-file-path-input] [role="img"]', elements => elements.length), 1);
// Native browser arrows cross the atomic host without entering its label.
await caret(page, 5); await page.keyboard.press("ArrowRight");
assert.equal(await page.evaluate(() => !!document.getSelection().anchorNode?.parentElement?.closest('[role="img"]')), false);
await page.keyboard.press("Backspace"); await expectValue(page, "read now");
await shortcut(page, "KeyZ"); await expectValue(page, `read ${token} now`);
await shortcut(page, "KeyZ", true); await expectValue(page, "read now");
await shortcut(page, "KeyZ"); await caret(page, 5); await page.keyboard.press("Delete"); await expectValue(page, "read now");
await shortcut(page, "KeyZ"); await expectValue(page, `read ${token} now`);
// Select the actual rendered label; copy must emit the full source token.
await page.$eval('[data-file-path-input] [role="img"] span', label => {
const range = document.createRange(); range.selectNodeContents(label); const selection = document.getSelection(); selection.removeAllRanges(); selection.addRange(range); document.dispatchEvent(new Event("selectionchange"));
});
await clipboardCommand(page, "copy");
assert.equal(await page.evaluate(() => navigator.clipboard.readText()), token);
await clipboardCommand(page, "cut", "Delete", 8);
await expectValue(page, "read now");
assert.equal(await page.evaluate(() => navigator.clipboard.readText()), token, "Shift+Delete is native cut, not editor-only deletion");
await shortcut(page, "KeyZ"); await expectValue(page, `read ${token} now`);
// Clipboard image-only input must not erase the current selected path.
await page.$eval('[data-file-path-input]', editor => {
const host = editor.querySelector('[data-file-path-source]'); const range = document.createRange(); range.selectNode(host); const selection = document.getSelection(); selection.removeAllRanges(); selection.addRange(range); document.dispatchEvent(new Event("selectionchange"));
const data = new DataTransfer(); data.items.add(new File(["image fixture"], "image.png", { type: "image/png" }));
editor.dispatchEvent(new ClipboardEvent("paste", { bubbles: true, cancelable: true, clipboardData: data }));
});
await expectValue(page, `read ${token} now`);
// Pasting HTML-bearing clipboard content is plain text, at the range.
await page.evaluate(() => navigator.clipboard.writeText('<img src=x onerror="alert(1)">\n中'));
await clipboardCommand(page, "paste");
await expectValue(page, 'read <img src=x onerror="alert(1)">\n中 now');
assert.equal(await page.$('[data-file-path-input] img'), null);
await fill(page, "readthis"); await caret(page, 4);
assert.equal(await syntheticDrop(page, ["/synthetic/one.pdf", "~/two`final.pdf"]), true);
await expectValue(page, "read `/synthetic/one.pdf` ``~/two`final.pdf`` this");
assert.equal(posts.length, 0, "typing/paste/drop must never submit");
await fill(page, "one visible line"); await caret(page, "one visible line".length);
const beforeNewline = await page.$eval(inputSelector, element => element.getBoundingClientRect().height);
await page.keyboard.down("Shift"); await page.keyboard.press("Enter"); await page.keyboard.up("Shift");
await expectValue(page, "one visible line\n");
const afterNewline = await page.$eval(inputSelector, element => {
const range = document.getSelection().getRangeAt(0); const rect = range.getBoundingClientRect();
return { height: element.getBoundingClientRect().height, caret: { top: rect.top, height: rect.height }, html: element.innerHTML };
});
// Record this independently so a visual editor regression does not hide
// the higher-priority actual Electron/target-routing evidence below.
try { assert.ok(afterNewline.height > beforeNewline, `Trailing newline must occupy a visible blank line: ${JSON.stringify({ beforeNewline, afterNewline })}`); }
catch (error) { visualFailures.push({ route, message: error.message }); }
await page.keyboard.type("second line");
const afterTyping = await read(page);
try { assert.equal(afterTyping, "one visible line\nsecond line", "Typing after Shift+Enter must stay after the newline"); }
catch (error) { visualFailures.push({ route, message: error.message }); }
assert.equal(posts.length, 0, "Shift+Enter is a newline, never submit");
await fill(page, "composer fallback"); await blur(page);
await syntheticDrop(page, ["/synthetic/default.pdf"]);
await expectValue(page, "composer fallback `/synthetic/default.pdf`");
// Small viewport: wrapped multiline content stays scrollable inside its editor.
await page.setViewport({ width: 900, height: 520 });
await fill(page, (`${token} a long synthetic line wrapping at narrow widths.\n`).repeat(24));
const sizing = await page.$eval(inputSelector, element => ({ height: element.clientHeight, scrollHeight: element.scrollHeight, overflowY: getComputedStyle(element).overflowY, bodyWidth: document.body.scrollWidth, viewport: innerWidth }));
assert.ok(sizing.scrollHeight > sizing.height && sizing.height > 0, JSON.stringify(sizing));
assert.ok(["auto", "scroll"].includes(sizing.overflowY));
assert.ok(sizing.bodyWidth <= sizing.viewport + 1, JSON.stringify(sizing));
const screenshot = path.join(home, `chrome-${route === "/" ? "home" : "new"}.png`); await page.screenshot({ path: screenshot }); screenshots.push({ surface: route, bytes: fs.statSync(screenshot).size, sizing });
await page.setViewport({ width: 1280, height: 900 });
checks.push(`${route}: native typing/chips/arrows/deletion/undo-redo/full-source clipboard/plaintext paste/caret drop/newlines/wrap-scroll`);
}
// Only an explicit Enter may initiate a send; agent endpoint is refused.
await fill(page, "explicit synthetic send `/synthetic/one.pdf`");
await page.keyboard.press("Enter");
await page.waitForFunction(() => /^\/chat\/chat_/.test(location.pathname));
await new Promise((resolve, reject) => {
const deadline = Date.now() + 15000;
const inspect = () => { if (posts.length) resolve(); else if (Date.now() > deadline) reject(new Error("Explicit Enter did not send")); else setTimeout(inspect, 50); }; inspect();
});
assert.equal(posts.length, 1); assert.equal(posts[0].body.input.text, "explicit synthetic send `/synthetic/one.pdf`");
checks.push("Explicit Enter submitted exactly one full-source prompt; endpoint intercepted/refused");
// These raw JSON fixtures are synthetic native records, never store imports.
const reading = '[data-testid="reading-thinking-field"] [data-file-path-input]';
const question = '[data-testid="thinking-field"] [data-file-path-input]';
const practice = '[data-testid="practice-thinking-field"] [data-file-path-input]';
await open(page, "/chat/path_teach");
await page.waitForSelector('[data-testid="teach-check-reveal"]', { visible: true });
assert.equal(await page.$eval('[data-testid="user-message"] [role="img"]', chip => chip.title), "/synthetic/transcript notes.pdf");
assert.equal(await page.$eval('[data-testid="user-message"] [role="img"]', chip => chip.textContent), "transcript notes.pdf");
checks.push("Seeded sent learner message renders a filename chip with full-path title; raw record retains quoted full path");
await blur(page); await syntheticDrop(page, ["/synthetic/reading.pdf"]);
await expectValue(page, "`/synthetic/reading.pdf`", reading);
assert.equal(await read(page, question), "");
checks.push("Unrevealed teaching check: unfocused drop lands in reading-thinking via dictation registry");
await page.locator('[data-testid="teach-check-reveal"]').click();
await page.waitForSelector(question, { visible: true });
await fill(page, "AB", reading); await caret(page, 1, reading);
await syntheticDrop(page, ["/synthetic/focused-reading.pdf"]);
await expectValue(page, "A `/synthetic/focused-reading.pdf` B", reading);
assert.equal(await read(page, question), "", "focused reading field overrides opened question default");
await blur(page); await syntheticDrop(page, ["/synthetic/question.pdf"]);
await expectValue(page, "`/synthetic/question.pdf`", question);
checks.push("Opened teaching check: current reading focus wins; otherwise default routes to question-thinking");
if (await foldDock(page, '[data-testid="question-dock-bar"]')) {
await blur(page); await syntheticDrop(page, ["/synthetic/collapsed-question.pdf"]);
await expectValue(page, "`/synthetic/question.pdf` `/synthetic/collapsed-question.pdf`", question);
await page.locator('[data-testid="question-dock-bar"]').click();
await page.waitForSelector(question, { visible: true });
assert.equal(await read(page, question), "`/synthetic/question.pdf` `/synthetic/collapsed-question.pdf`");
checks.push("Collapsed revealed teaching dock keeps question-thinking claim/state through expansion");
}
await open(page, "/chat/path_probe"); await page.waitForSelector(question, { visible: true });
await blur(page); await syntheticDrop(page, ["/synthetic/probe.pdf"]);
await expectValue(page, "`/synthetic/probe.pdf`", question);
checks.push("Probe-phase pending question default routes to question-thinking");
await open(page, "/chat/path_practice"); await page.waitForSelector(practice, { visible: true });
await blur(page); await syntheticDrop(page, ["/synthetic/practice.pdf"]);
await expectValue(page, "`/synthetic/practice.pdf`", practice);
checks.push("Expanded practice thinking is the unfocused drop default");
if (await foldDock(page, '[data-testid="practice-dock-bar"]')) {
await blur(page); await syntheticDrop(page, ["/synthetic/collapsed-practice.pdf"]);
// Practice keeps the owning hook/state mounted, but hides its field
// by returning the bar. Inspect the controlled text after expansion.
await page.waitForFunction(() => document.querySelector('[role="status"]')?.textContent.includes("File paths attached"));
await page.locator('[data-testid="practice-dock-bar"]').click();
await page.waitForSelector(practice, { visible: true });
await expectValue(page, "`/synthetic/practice.pdf` `/synthetic/collapsed-practice.pdf`", practice);
checks.push("Collapsed practice thinking keeps its claim/state through expansion without submission");
}
assert.equal(posts.length, 1, "all claim/drop cases stage text only");
}
// Actual Electron main + actual sandboxed preload. Temporary launcher sets
// userData BEFORE production main appends its -dev suffix or takes a lock.
const debugPort = await freePort();
const launcher = path.join(home, "electron-launcher.cjs");
fs.mkdirSync(path.join(home, "desktop-profile"), { recursive: true });
fs.mkdirSync(path.join(home, "desktop-profile-dev"), { recursive: true });
fs.writeFileSync(launcher, `const {app,session}=require('electron');\napp.setPath('home',${JSON.stringify(home)});\napp.setPath('userData',${JSON.stringify(path.join(home, "desktop-profile"))});\napp.commandLine.appendSwitch('remote-debugging-port',${JSON.stringify(String(debugPort))});\napp.commandLine.appendSwitch('disable-background-networking');\napp.commandLine.appendSwitch('disable-sync');\napp.whenReady().then(()=>session.defaultSession.webRequest.onBeforeRequest((details,cb)=>{let u;try{u=new URL(details.url)}catch{return cb({cancel:true})}cb({cancel:!(${JSON.stringify(origin)}===u.origin||(u.protocol==='ws:'&&u.host===${JSON.stringify(new URL(origin).host)})||['about:','data:','devtools:'].includes(u.protocol))});}));\nrequire(${JSON.stringify(path.join(root, "electron", "main.cjs"))});\nconsole.log('LH_FILE_PATH_PROFILE '+JSON.stringify({home:app.getPath('home'),userData:app.getPath('userData')}));\n`);
const electron = spawn(require("electron"), [launcher], { cwd: root, env: { ...env, LH_DEV_URL: origin }, stdio: ["ignore", "pipe", "pipe"] }); children.push(electron);
const captureElectron = data => { electronLogs = (electronLogs + data).slice(-24000); }; electron.stdout.on("data", captureElectron); electron.stderr.on("data", captureElectron);
await startup(electron, () => electronLogs, /DevTools listening on (ws:\/\/[^\s]+)/, 45000);
const profile = JSON.parse(electronLogs.match(/LH_FILE_PATH_PROFILE ([^\r\n]+)/)[1]);
assert.equal(profile.home, home); assert.equal(profile.userData, path.join(home, "desktop-profile-dev"));
const desktopLaunch = { executable: require("electron"), args: [launcher], LH_DEV_URL: origin, HOME: home, userDataBeforeMain: path.join(home, "desktop-profile"), actualProfile: profile, cleanup: "all temporary profiles/data/fixtures removed on exit" };
const endpoint = electronLogs.match(/DevTools listening on (ws:\/\/[^\s]+)/)[1];
const desktop = await puppeteer.connect({ browserWSEndpoint: endpoint }); browsers.push(desktop);
const desktopPage = await desktop.waitForTarget(target => target.type() === "page").then(target => target.page());
await instrument(desktopPage); page = desktopPage;
await open(desktopPage, "/chat/new");
assert.equal(await desktopPage.evaluate(() => typeof window.lh?.getPathForFile), "function");
const fixturePath = path.join(home, "actual OS-backed α `notes`.pdf"); fs.writeFileSync(fixturePath, "%PDF-1.7\nsynthetic offline fixture\n");
await desktopPage.evaluate(() => { const input = document.createElement("input"); input.type = "file"; input.id = "os-backed-fixture"; document.body.append(input); });
await (await desktopPage.$("#os-backed-fixture")).uploadFile(fixturePath);
const resolved = await desktopPage.evaluate(() => {
const input = document.querySelector("#os-backed-fixture");
return { count: input.files.length, actual: window.lh.getPathForFile(input.files[0]), virtual: window.lh.getPathForFile(new File(["virtual"], "/not/a/backing/path.pdf")) };
});
assert.equal(resolved.count, 1); assert.equal(resolved.actual, fixturePath); assert.equal(resolved.virtual, "");
await fill(desktopPage, "readthis"); await caret(desktopPage, 4);
const beforeDesktopDrop = posts.length;
assert.equal(await desktopPage.evaluate(() => {
const file = document.querySelector("#os-backed-fixture").files[0];
for (const method of ["text", "arrayBuffer", "stream"]) Object.defineProperty(file, method, { value() { throw new Error("No file content read"); } });
const transfer = new DataTransfer(); transfer.items.add(file);
const event = new DragEvent("drop", { bubbles: true, cancelable: true, dataTransfer: transfer });
document.querySelector('[data-testid="file-path-drop-zone"]').dispatchEvent(event); return event.defaultPrevented;
}), true);
const expectedQuoted = "``" + fixturePath + "``";
await expectValue(desktopPage, `read ${expectedQuoted} this`);
assert.equal(await desktopPage.$eval('[data-file-path-input] [role="img"]', chip => chip.title), fixturePath);
assert.equal(posts.length, beforeDesktopDrop, "actual OS-backed desktop drop must stage only");
assert.ok(fs.existsSync(fixturePath));
assert.deepEqual(fs.readdirSync(path.join(home, "data")).filter(name => /upload|attachment/i.test(name)), []);
checks.push("Actual Electron production preload: Puppeteer-selected OS-backed File resolves exact Unicode/backtick full path; virtual File returns empty; real DataTransfer drop inserts at caret without submit or file reads");
assert.deepEqual(errors, []);
console.log(JSON.stringify({ checks, visualFailures, coverageFailures, screenshots, desktopLaunch, postsIntercepted: posts.length, blockedExternalRequests: blocked, isolation: "temporary HOME/data/auth/browser+Electron profiles; synthetic catalog; agent POSTs refused; no providers", origin }, null, 2));
assert.deepEqual(visualFailures, [], "Visible trailing-newline/caret projection must pass");
assert.deepEqual(coverageFailures, [], "Required collapsed target coverage must run");
} catch (error) {
console.error(error);
console.error(JSON.stringify({ checks, visualFailures, coverageFailures, errors, consoleErrors, networkFailures: networkFailures.slice(-30), posts, origin, url: page?.url(), dom: await page?.evaluate(() => ({ inputs: [...document.querySelectorAll('[data-file-path-input]')].map(element => ({ placeholder: element.getAttribute("data-placeholder"), text: element.textContent, html: element.innerHTML.slice(0, 1200) })), text: document.body.innerText.slice(-2600) })).catch(() => null), serverLogs, electronLogs }, null, 2));
process.exitCode = 1;
} finally {
shuttingDown = true;
for (const browser of browsers.reverse()) { try { await browser.close(); } catch {} }
for (const child of children.reverse()) await stop(child);
fs.rmSync(home, { recursive: true, force: true });
}
+10 -7
View File
@@ -109,14 +109,14 @@ try {
// Release synthetic streams from prior cases rather than pin slots.
await Promise.all(held.splice(0).map(request => request.abort("aborted").catch(() => {})));
await page.goto(origin + route, { waitUntil: "domcontentloaded", timeout: 120000 });
await page.waitForSelector(route === "/" ? "textarea" : '[data-testid="composer"] textarea');
await page.waitForSelector(route === "/" ? "[data-file-path-input]" : '[data-testid="composer"] [data-file-path-input]');
await page.waitForFunction(() => !document.querySelector('[data-testid="setup-cta"]') && !document.querySelector('[data-testid="setup-cta-keys"]'));
}
async function fill(text) {
await page.evaluate(value => {
const textarea = document.querySelector('[data-testid="composer"] textarea') || document.querySelector("textarea");
Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype, "value").set.call(textarea, value);
textarea.dispatchEvent(new Event("input", { bubbles: true }));
const input = document.querySelector('[data-testid="composer"] [data-file-path-input]') || document.querySelector("[data-file-path-input]");
input.textContent = value;
input.dispatchEvent(new Event("input", { bubbles: true }));
}, text);
}
async function submit() {
@@ -127,7 +127,10 @@ try {
await page.locator('button[aria-label="Start session"], [data-testid="composer"] button[aria-label="Send"]').click();
}
async function stash(id) { return page.evaluate(chat => sessionStorage.getItem(`lh.openingPrompt.${chat}`), id); }
async function assertDraft(text) { await page.waitForFunction(value => document.querySelector('[data-testid="composer"] textarea')?.value === value, {}, text); }
async function assertDraft(text) { await page.waitForFunction(value => {
const input = document.querySelector('[data-testid="composer"] [data-file-path-input]');
return input && [...input.childNodes].map(node => node.nodeType === 1 ? node.getAttribute("data-file-path-source") ?? node.textContent : node.textContent).join("") === value;
}, {}, text); }
const long = "A full synthetic syllabus — 世界 & ? #. ".repeat(1200).trim();
for (const route of ["/", "/chat/new"]) {
for (const text of ["a short synthetic topic", long]) {
@@ -163,7 +166,7 @@ try {
await fill(long); await submit();
await page.waitForSelector('[role="alert"]');
assert.equal(new URL(page.url()).pathname, route);
assert.equal(await page.$eval("textarea", element => element.value), long);
assert.equal(await page.$eval("[data-file-path-input]", element => [...element.childNodes].map(node => node.nodeType === 1 ? node.getAttribute("data-file-path-source") ?? node.textContent : node.textContent).join("")), long);
assert.equal(posts.length, sendCount);
assert.equal(creates.length, createCount + 1);
await page.evaluate(() => { window.failOpeningStorage = false; });
@@ -239,7 +242,7 @@ try {
console.log(JSON.stringify({ checks, pageErrors: errors, isolation: "synthetic catalog/POST+streams; empty auth; no providers", origin }, null, 2));
} catch (err) {
console.error(err);
console.error(JSON.stringify({ checks, errors, url: page?.url(), dom: await page?.evaluate(() => ({ textareas: [...document.querySelectorAll('textarea')].map(e => ({ length: e.value.length, placeholder: e.placeholder })), buttons: [...document.querySelectorAll('button[aria-label="Send"], button[aria-label="Start session"]')].map(e => ({ disabled: e.disabled, label: e.getAttribute('aria-label') })), alerts: [...document.querySelectorAll('[role="alert"]')].map(e => e.textContent), failStorage: window.failOpeningStorage, text: document.body.innerText.slice(-1200) })).catch(() => null) }, null, 2));
console.error(JSON.stringify({ checks, errors, url: page?.url(), dom: await page?.evaluate(() => ({ inputs: [...document.querySelectorAll('[data-file-path-input]')].map(e => ({ length: [...e.childNodes].map(n => n.nodeType === 1 ? n.getAttribute('data-file-path-source') ?? n.textContent : n.textContent).join('').length, placeholder: e.getAttribute('data-placeholder') })), buttons: [...document.querySelectorAll('button[aria-label="Send"], button[aria-label="Start session"]')].map(e => ({ disabled: e.disabled, label: e.getAttribute('aria-label') })), alerts: [...document.querySelectorAll('[role="alert"]')].map(e => e.textContent), failStorage: window.failOpeningStorage, text: document.body.innerText.slice(-1200) })).catch(() => null) }, null, 2));
console.error(logs); process.exitCode = 1;
} finally {
await browser?.close();
+86
View File
@@ -0,0 +1,86 @@
/** A lossless projection of plain learner text, not a Markdown parser. */
export type FilePathPart =
| { kind: "text"; text: string; start: number; end: number }
| { kind: "path"; text: string; path: string; start: number; end: number };
// No filesystem access or normalization: even punctuation and backslashes can
// be legitimate POSIX filename characters. Only rooted syntax is required.
function isFilePath(path: string): boolean {
if (/[\u0000-\u001f\u007f-\u009f\u2028\u2029]/u.test(path)) return false;
return path.startsWith("/")
|| path.startsWith("~/")
|| /^[a-z]:[\\/]/i.test(path)
|| /^\\\\[^\\/]+[\\/][^\\/]+(?:[\\/]|$)/u.test(path);
}
/**
* Wrap a rooted path for storage/transmission as ordinary, lossless text.
* CommonMark's one-space padding prevents edge backticks joining delimiters.
*/
export function quoteFilePath(path: string): string {
if (!isFilePath(path)) throw new TypeError("Expected a single-line rooted file path");
let width = 1;
for (const run of path.matchAll(/`+/g)) width = Math.max(width, run[0].length + 1);
const delimiter = "`".repeat(width);
const padding = path.startsWith("`") || path.endsWith("`") ? " " : "";
return `${delimiter}${padding}${path}${padding}${delimiter}`;
}
/**
* Recognize every complete backtick-wrapped rooted path, even beside words.
* Delimiters are whole runs of exactly equal length; a run inside another
* code span is never reconsidered as a partial path token. Offsets are JS
* string (UTF-16) offsets and `text` always slices the exact original input.
*/
export function parseFilePaths(text: string): FilePathPart[] {
const runs = Array.from(text.matchAll(/`+/g), (match) => ({
start: match.index,
end: match.index + match[0].length,
width: match[0].length,
}));
// The next same-width run lets unmatched openers remain plain text without
// repeatedly scanning the rest of a long draft.
const next = new Map<number, number>();
const closing = new Map<number, number>();
for (let i = runs.length - 1; i >= 0; i--) {
const width = runs[i]!.width;
const match = next.get(width);
if (match !== undefined) closing.set(i, match);
next.set(width, i);
}
const parts: FilePathPart[] = [];
let plainStart = 0;
for (let i = 0; i < runs.length; i++) {
const closeIndex = closing.get(i);
if (closeIndex === undefined) continue;
const open = runs[i]!;
const close = runs[closeIndex]!;
let path = text.slice(open.end, close.start);
// CommonMark strips exactly one space at each edge, not all whitespace.
if (path.startsWith(" ") && path.endsWith(" ") && /[^ ]/.test(path)) {
path = path.slice(1, -1);
}
if (isFilePath(path)) {
if (plainStart < open.start) {
parts.push({ kind: "text", text: text.slice(plainStart, open.start), start: plainStart, end: open.start });
}
parts.push({ kind: "path", text: text.slice(open.start, close.end), path, start: open.start, end: close.end });
plainStart = close.end;
}
i = closeIndex;
}
if (plainStart < text.length) {
parts.push({ kind: "text", text: text.slice(plainStart), start: plainStart, end: text.length });
}
return parts;
}
/** Basename for display only; the original full path remains untouched. */
export function filePathLabel(path: string): string {
const windows = /^[a-z]:[\\/]/i.test(path) || path.startsWith("\\\\");
const separator = windows ? /[\\/]/ : /\//;
const trimmed = windows ? path.replace(/[\\/]+$/, "") : path.replace(/\/+$/, "");
if (!trimmed || (windows && /^[a-z]:$/i.test(trimmed)) || trimmed === "~") return path;
return trimmed.split(separator).at(-1) || path;
}
+2
View File
@@ -42,6 +42,8 @@ export * from "./math-delims.ts";
export * from "./native-messages.ts";
export * from "./compaction.ts";
export * from "./input-events.ts";
export { parseFilePaths, quoteFilePath, filePathLabel } from "./file-paths.ts";
export type { FilePathPart } from "./file-paths.ts";
// Wave D (owner-approved 2026-09-26): the client view model — the grouping
// canon (one continuous assistant document, step separators) ported from
// the deleted server projection to the client side. Pure, browser-safe.
+1 -1
View File
@@ -96,7 +96,7 @@ describe("desktop window chrome", () => {
runInNewContext(readFileSync(new URL("../electron/preload.cjs", import.meta.url), "utf8"), context);
assert.equal(bridge.platform, "win32");
assert.deepEqual(Object.keys(bridge).sort(), [
"chatgpt", "checkForUpdates", "expandWindow", "getUpdateState", "getZoom", "installUpdate", "onOpenSettings", "onUpdateStatus", "onZoom", "platform", "subscriptions",
"chatgpt", "checkForUpdates", "expandWindow", "getPathForFile", "getUpdateState", "getZoom", "installUpdate", "onOpenSettings", "onUpdateStatus", "onZoom", "platform", "subscriptions",
]);
assert.deepEqual(Object.keys(bridge.subscriptions as object).sort(), ["cancel", "consent", "disconnect", "login", "onChange", "open", "respond", "retry", "status"]);
});
+89
View File
@@ -0,0 +1,89 @@
import React from "react";
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import { renderToStaticMarkup } from "react-dom/server";
import { parseHTML } from "linkedom";
import { FilePathChip, PathText } from "../components/file-path-chip.tsx";
import { quoteFilePath } from "../src/core/file-paths.ts";
function documentFor(node: React.ReactNode) {
return parseHTML(`<html><body>${renderToStaticMarkup(node)}</body></html>`).document;
}
describe("FilePathChip", () => {
it("shows a filename and decorative type icon with an accessible full path", () => {
const path = "/tmp/course notes/α.pdf";
const document = documentFor(<FilePathChip path={path} />);
const chip = document.body.firstElementChild!;
assert.equal(chip.tagName, "SPAN");
assert.equal(chip.textContent, "α.pdf");
assert.equal(chip.getAttribute("title"), path);
assert.equal(chip.getAttribute("role"), "img");
assert.equal(chip.getAttribute("aria-label"), path);
assert.equal(chip.querySelector("svg")!.getAttribute("aria-hidden"), "true");
assert.ok(chip.querySelector(".lucide-file-text"));
assert.equal(document.querySelector("a, button, input, [tabindex]"), null);
assert.equal(chip.getAttributeNames().some((name) => name.toLowerCase() === "contenteditable"), false);
assert.ok(chip.classList.contains("inline-flex"));
assert.ok(chip.querySelector(".truncate"));
});
it("supports atomic editor rendering without requiring an editor in transcripts", () => {
const document = documentFor(<FilePathChip path="C:\\notes\\chapter.txt" contentEditable={false} className="editor-chip" />);
const chip = document.body.firstElementChild!;
// React SSR emits contentEditable; linkedom preserves attribute case.
const editableAttribute = chip.getAttributeNames().find((name) => name.toLowerCase() === "contenteditable")!;
assert.equal(chip.getAttribute(editableAttribute), "false");
assert.ok(chip.classList.contains("editor-chip"));
assert.equal(chip.textContent, "chapter.txt");
});
it("chooses type icons without accessing files or changing their names", () => {
for (const [filename, icon] of [
["figure.PNG", "file-image"], ["lecture.mp3", "file-headphone"], ["clip.mp4", "file-play"],
["course.zip", "file-archive"], ["data.csv", "file-spreadsheet"], ["example.ts", "file-code"],
["notes.md", "file-text"], ["unknown.xyz", "file"],
]) {
const document = documentFor(<FilePathChip path={`/not/real/${filename}`} />);
assert.equal(document.body.textContent, filename);
assert.ok(document.querySelector(`.lucide-${icon}`));
}
});
it("keeps dangerous filename HTML inert in both label and title", () => {
const path = '/tmp/<img src=x onerror="alert(1)">.pdf';
const document = documentFor(<FilePathChip path={path} />);
assert.equal(document.body.textContent, '<img src=x onerror="alert(1)">.pdf');
assert.equal(document.body.firstElementChild!.getAttribute("title"), path);
assert.equal(document.querySelector("img, script, [onerror], a"), null);
});
});
describe("PathText", () => {
it("keeps ordinary text, markup-looking text and inline code completely unchanged", () => {
const text = 'Ordinary <b>prose</b>\n`code` ./relative `file:///tmp/a` & words';
const document = documentFor(<PathText text={text} />);
assert.equal(document.body.textContent, text);
assert.equal(document.body.children.length, 0);
assert.equal(renderToStaticMarkup(<PathText text="" />), "");
});
it("renders all quoted platforms, adjacent words and paths containing backticks", () => {
const paths = ["/tmp/a.pdf", "C:\\notes\\b.md", "\\\\server\\share\\c.png", "~/d`e.txt"];
const text = `before${paths.map(quoteFilePath).join(" then ")}after`;
const document = documentFor(<PathText text={text} />);
const chips = document.querySelectorAll('[role="img"]');
assert.equal(chips.length, paths.length);
assert.deepEqual(Array.from(chips, (chip) => chip.getAttribute("title")), paths);
assert.equal(document.body.textContent, "beforea.pdf then b.md then c.png then d`e.txtafter");
assert.equal(text, `before${paths.map(quoteFilePath).join(" then ")}after`, "presentation never rewrites saved text");
});
it("does not interpret HTML in ordinary text or path labels", () => {
const path = '/tmp/<img src=x onerror="alert(1)">.png';
const document = documentFor(<PathText text={`<script>alert(1)</script> ${quoteFilePath(path)}`} />);
assert.equal(document.querySelector("script, img, [onerror], a"), null);
assert.equal(document.querySelector('[role="img"]')!.getAttribute("title"), path);
assert.equal(document.body.textContent, '<script>alert(1)</script> <img src=x onerror="alert(1)">.png');
});
});
+223
View File
@@ -0,0 +1,223 @@
import React, { act } from "react";
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { afterEach, test } from "node:test";
import { parseHTML } from "linkedom";
import { FilePathDropZone } from "../components/file-path-drop-zone.tsx";
import { useDictationTarget } from "../components/dictation.tsx";
import { PathInput } from "../components/path-input.tsx";
import { readPathInput, restorePathSelection } from "../components/path-input-dom.ts";
import { quoteFilePath } from "../src/core/file-paths.ts";
let cleanup: (() => Promise<void>) | undefined;
afterEach(async () => { await cleanup?.(); cleanup = undefined; });
/** No native DataTransfer/Selection in linkedom. These shims exercise React's
* real bubbling handlers and the editor's logical caret contract, not OS drag. */
async function fixture({ desktop = true, claimed = true } = {}) {
const { window: win, document: doc } = parseHTML("<html><body><div id='root'></div></body></html>");
let active: Element = doc.body;
Object.defineProperty(doc, "activeElement", { get: () => active });
const selection = {
anchorNode: null as Node | null, anchorOffset: 0, focusNode: null as Node | null, focusOffset: 0,
setBaseAndExtent(anchorNode: Node, anchorOffset: number, focusNode: Node, focusOffset: number) {
Object.assign(this, { anchorNode, anchorOffset, focusNode, focusOffset });
},
};
Object.assign(doc, { getSelection: () => selection, oninput: null });
win.HTMLElement.prototype.focus = function () { active = this; };
const resolved: File[] = [];
const backingPaths = new Map<File, string | Error>();
const appends: string[] = [];
let byteReads = 0;
let uploads = 0;
let submits = 0;
let keys = 0;
let value = "readthis";
const bridge = desktop ? { getPathForFile(file: File) {
resolved.push(file);
const result = backingPaths.get(file);
if (result instanceof Error) throw result;
return result ?? "";
} } : undefined;
const previous = new Map<string, PropertyDescriptor | undefined>();
for (const [name, next] of Object.entries({
React, window: win, document: doc, Node: win.Node, HTMLElement: win.HTMLElement, Element: win.Element,
Event: win.Event, CustomEvent: win.CustomEvent, IS_REACT_ACT_ENVIRONMENT: true,
fetch: () => { uploads++; throw new Error("No network expected for file drops"); },
})) {
previous.set(name, Object.getOwnPropertyDescriptor(globalThis, name));
Object.defineProperty(globalThis, name, { configurable: true, writable: true, value: next });
}
const oldBridge = Object.getOwnPropertyDescriptor(win, "lh");
Object.defineProperty(win, "lh", { configurable: true, value: bridge });
const { createRoot } = await import("react-dom/client");
const root = createRoot(doc.getElementById("root")!);
function Harness() {
useDictationTarget((text) => appends.push(text), claimed);
return <>
<aside data-testid="sidebar">Sidebar</aside>
<FilePathDropZone>
<form onSubmit={(event) => { event.preventDefault(); submits++; }}>
<PathInput data-testid="editor" value={value} onValueChange={(next) => { value = next; draw(); }} onKeyDown={() => { keys++; }} />
<button type="button" data-testid="elsewhere">Elsewhere</button>
<div data-testid="content">Content away from the input</div>
</form>
</FilePathDropZone>
</>;
}
function draw() { root.render(<Harness />); }
await act(async () => { draw(); });
cleanup = async () => {
await act(async () => { root.unmount(); });
if (oldBridge) Object.defineProperty(win, "lh", oldBridge);
else delete (win as unknown as Record<string, unknown>).lh;
for (const [name, descriptor] of previous) {
if (descriptor) Object.defineProperty(globalThis, name, descriptor);
else delete (globalThis as Record<string, unknown>)[name];
}
};
const editor = doc.querySelector<HTMLElement>('[data-testid="editor"]')!;
function file(name: string, path?: string | Error) {
const result = new File(["synthetic bytes must never be read"], name, { type: "application/pdf" });
for (const method of ["text", "arrayBuffer", "stream"] as const) {
Object.defineProperty(result, method, { value: () => { byteReads++; throw new Error("No file content reads"); } });
}
if (path !== undefined) backingPaths.set(result, path);
return result;
}
function transfer(files: File[], directories: number[] = [], types = ["Files"]) {
return {
types,
files: Object.assign([...files], { item: (index: number) => files[index] ?? null }),
items: files.map((file, index) => ({ kind: "file", type: file.type, getAsFile: () => file, webkitGetAsEntry: () => ({ isDirectory: directories.includes(index), isFile: !directories.includes(index) }) })),
dropEffect: "none",
};
}
async function event(type: string, dataTransfer = transfer([]), target: Element = doc.querySelector('[data-testid="content"]')!) {
const event = Object.assign(new win.Event(type, { bubbles: true, cancelable: true }), { dataTransfer });
await act(async () => { target.dispatchEvent(event); });
return event;
}
return {
doc, editor, appends, resolved, file, transfer, event,
get value() { return value; },
get status() { return doc.querySelector('[role="status"]')?.textContent ?? ""; },
focusEditor(offset: number) {
editor.focus();
restorePathSelection(editor, { anchor: offset, focus: offset });
doc.dispatchEvent(new win.Event("selectionchange"));
},
assertPathOnly() { assert.equal(byteReads, 0); assert.equal(uploads, 0); assert.equal(submits, 0); assert.equal(keys, 0); },
};
}
test("desktop drop inserts quoted paths at the focused editor caret instead of the dictation claimant", async () => {
const f = await fixture();
f.focusEditor(4);
const paths = ["/actual/course notes.pdf", "C:\\course\\chapter`one.pdf", "\\\\host\\share\\図.png"];
const files = paths.map((path, index) => f.file(`display-name-${index}.pdf`, path));
const dropped = await f.event("drop", f.transfer(files));
assert.equal(dropped.defaultPrevented, true);
assert.equal(f.value, `read ${paths.map(quoteFilePath).join(" ")} this`);
assert.equal(readPathInput(f.editor), f.value);
assert.equal(f.doc.activeElement, f.editor);
assert.deepEqual(f.appends, [], "focused editor wins over the registered question field");
assert.deepEqual(f.resolved, files, "only Electron's full path, never File.name, enters the draft");
assert.match(f.status, /File paths attached/);
f.assertPathOnly();
});
test("unfocused drop uses the dictation target registry, not the last focused editor", async () => {
const f = await fixture();
f.focusEditor(4);
f.doc.querySelector<HTMLElement>('[data-testid="elsewhere"]')!.focus();
const paths = ["~/notes one.pdf", "/tmp/notes`final`"];
await f.event("drop", f.transfer(paths.map((path) => f.file("not-a-physical-path.pdf", path))));
assert.deepEqual(f.appends, [paths.map(quoteFilePath).join(" ")]);
assert.equal(f.value, "readthis", "saved editor caret must not override current default target");
f.assertPathOnly();
});
test("browser drop prevents navigation, explains desktop-only local paths and never infers File.name", async () => {
const f = await fixture({ desktop: false });
const transfer = f.transfer([f.file("/not/a/backing/path.pdf")]);
const enter = await f.event("dragenter", transfer);
assert.equal(enter.defaultPrevented, true);
assert.match(f.doc.querySelector('[data-testid="file-path-drop-indicator"]')!.textContent!, /desktop app/);
const over = await f.event("dragover", transfer);
assert.equal(over.defaultPrevented, true);
assert.equal(transfer.dropEffect, "copy");
const drop = await f.event("drop", transfer);
assert.equal(drop.defaultPrevented, true);
assert.match(f.status, /Local file paths can only be attached in the desktop app/);
assert.match(f.status, /paste a full path instead/);
assert.equal(f.doc.querySelector('[data-testid="file-path-drop-indicator"]'), null);
assert.equal(f.value, "readthis");
assert.deepEqual(f.appends, []);
assert.deepEqual(f.resolved, []);
f.assertPathOnly();
});
test("invalid, virtual and directory items are skipped while valid backing paths stay ordered", async () => {
const f = await fixture();
const files = [
f.file("virtual.pdf"), f.file("bad.pdf", "relative.pdf"), f.file("broken.pdf", new Error("not backed")),
f.file("folder", "/directory"), f.file("valid.pdf", "/existing/valid.pdf"),
f.file("multiline.pdf", "/a\nb.pdf"), f.file("NUL.pdf", "/a\0b.pdf"), f.file("url.pdf", "file:///a.pdf"),
];
await f.event("drop", f.transfer(files, [3]));
assert.deepEqual(f.appends, ["`/existing/valid.pdf`"]);
assert.equal(f.resolved.includes(files[3]!), false, "directory metadata prevents resolving a folder");
assert.match(f.status, /Some dropped items had no available file path/);
f.assertPathOnly();
});
test("a drop with no backing paths or no destination has a truthful notice and stages nothing", async () => {
const f = await fixture({ claimed: false });
const missing = await f.event("drop", f.transfer([f.file("virtual.pdf")]));
assert.equal(missing.defaultPrevented, true);
assert.match(f.status, /No local file paths were available/);
await f.event("drop", f.transfer([f.file("valid.pdf", "/existing/valid.pdf")]));
assert.match(f.status, /Open a chat to attach file paths/);
assert.deepEqual(f.appends, []);
assert.equal(f.value, "readthis");
f.assertPathOnly();
});
test("non-file drags stay native; sidebar file drops prevent navigation without attaching", async () => {
const f = await fixture();
const plain = f.transfer([], [], ["text/plain"]);
for (const type of ["dragenter", "dragover", "drop"]) {
assert.equal((await f.event(type, plain)).defaultPrevented, false);
}
const sidebar = f.doc.querySelector('[data-testid="sidebar"]')!;
assert.equal((await f.event("drop", f.transfer([f.file("notes.pdf", "/existing/notes.pdf")]), sidebar)).defaultPrevented, true);
assert.deepEqual(f.resolved, []);
assert.deepEqual(f.appends, []);
assert.equal(f.status, "");
f.assertPathOnly();
});
test("nested enter/leave depth keeps the drop overlay until the zone is left; notice is dismissible", async () => {
const f = await fixture({ desktop: false });
const transfer = f.transfer([f.file("notes.pdf")]);
await f.event("dragenter", transfer);
await f.event("dragenter", transfer);
await f.event("dragleave", transfer);
assert.ok(f.doc.querySelector('[data-testid="file-path-drop-indicator"]'));
await f.event("dragleave", transfer);
assert.equal(f.doc.querySelector('[data-testid="file-path-drop-indicator"]'), null);
await f.event("drop", transfer);
assert.notEqual(f.status, "");
await act(async () => { f.doc.querySelector<HTMLElement>('[aria-label="Dismiss attachment notice"]')!.click(); });
assert.equal(f.status, "");
f.assertPathOnly();
});
test("production AppShell places the sidebar outside its content-only drop zone", () => {
const source = readFileSync(new URL("../components/sidebar.tsx", import.meta.url), "utf8");
const shell = source.slice(source.indexOf("export function AppShell("));
assert.match(shell, /<SessionsSidebar\s*\/>\s*<FilePathDropZone\b[^>]*>\{children\}<\/FilePathDropZone>/);
assert.doesNotMatch(shell, /<FilePathDropZone\b[^>]*>[\s\S]*<SessionsSidebar/);
});
+93
View File
@@ -0,0 +1,93 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { describe, it } from "node:test";
import { runInNewContext } from "node:vm";
interface PathBridge {
platform: string;
getPathForFile(file: unknown): string;
[key: string]: unknown;
}
/** Execute the actual preload in a VM, without Electron, IPC or app startup. */
function fixture(resolve: (file: unknown) => string) {
let bridge!: PathBridge;
const supplied: unknown[] = [];
const sent: unknown[][] = [];
const invoked: unknown[][] = [];
const imports: string[] = [];
const exposed: string[] = [];
const electron = {
contextBridge: { exposeInMainWorld(name: string, surface: PathBridge) { exposed.push(name); bridge = surface; } },
webUtils: { getPathForFile(file: unknown) { supplied.push(file); return resolve(file); } },
ipcRenderer: {
on() {}, removeListener() {},
send: (...args: unknown[]) => { sent.push(args); },
invoke: (...args: unknown[]) => { invoked.push(args); return Promise.resolve(); },
},
};
runInNewContext(readFileSync(new URL("../electron/preload.cjs", import.meta.url), "utf8"), {
process: { platform: "darwin", argv: [] },
require: (name: string) => { imports.push(name); assert.equal(name, "electron"); return electron; },
});
return { bridge, supplied, sent, invoked, imports, exposed, electron };
}
describe("path-only preload bridge", () => {
it("forwards the exact supplied File identity to webUtils, returning only the backing path without IPC", () => {
const file = new File(["synthetic fixture"], "display-name.pdf", { type: "application/pdf" });
let reads = 0;
for (const method of ["text", "arrayBuffer", "stream"] as const) {
Object.defineProperty(file, method, { value: () => { reads++; throw new Error("Must not read file content"); } });
}
const fullPath = "/actual/course/notes with `backticks`.pdf";
const f = fixture((supplied) => { assert.equal(supplied, file); return fullPath; });
assert.deepEqual(f.exposed, ["lh"]);
assert.deepEqual(f.imports, ["electron"]);
assert.equal(f.bridge.getPathForFile(file), fullPath);
assert.equal(f.supplied.length, 1);
assert.equal(f.supplied[0], file);
assert.notEqual(fullPath, file.name);
assert.equal(reads, 0);
assert.deepEqual(f.sent, []);
assert.deepEqual(f.invoked, []);
});
it("keeps Electron's empty result for a File without an OS backing path, never substituting its name", () => {
const virtual = new File(["virtual"], "/misleading/full/path.pdf");
const f = fixture(() => "");
assert.equal(f.bridge.getPathForFile(virtual), "");
assert.equal(f.supplied[0], virtual);
assert.deepEqual(f.sent, []);
assert.deepEqual(f.invoked, []);
});
it("preserves webUtils errors for invalid File arguments instead of inventing a path", () => {
const invalid = { name: "/misleading/filename.pdf", path: "/not/a/capability.pdf" };
const error = new TypeError("Expected a File");
const f = fixture((supplied) => { assert.equal(supplied, invalid); throw error; });
assert.throws(() => f.bridge.getPathForFile(invalid), (caught) => caught === error);
assert.equal(f.supplied[0], invalid);
assert.deepEqual(f.sent, []);
assert.deepEqual(f.invoked, []);
});
it("adds just the narrow path resolver to the explicit surface, without exposing Electron or filesystem APIs", () => {
const f = fixture(() => "");
assert.deepEqual(Object.keys(f.bridge).sort(), [
"chatgpt", "checkForUpdates", "expandWindow", "getPathForFile", "getUpdateState", "getZoom",
"installUpdate", "onOpenSettings", "onUpdateStatus", "onZoom", "platform", "subscriptions",
]);
assert.equal(f.bridge.platform, "darwin");
for (const name of ["webUtils", "ipcRenderer", "electron", "fs", "require", "readFile", "writeFile", "uploadFile", "openPath"]) {
assert.equal(Object.hasOwn(f.bridge, name), false, name);
}
assert.notEqual(f.bridge.getPathForFile, f.electron.webUtils.getPathForFile, "webUtils itself is not exposed");
});
it("retains context isolation, sandbox and disabled Node integration in the production window", () => {
const main = readFileSync(new URL("../electron/main.cjs", import.meta.url), "utf8");
assert.match(main, /webPreferences:\s*\{\s*contextIsolation:\s*true,\s*sandbox:\s*true,\s*nodeIntegration:\s*false,/);
assert.match(main, /preload:\s*path\.join\(__dirname,\s*"preload\.cjs"\)/);
});
});
+127
View File
@@ -0,0 +1,127 @@
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import { filePathLabel, parseFilePaths, quoteFilePath } from "../src/core/file-paths.ts";
function paths(text: string) {
return parseFilePaths(text).flatMap((part) => part.kind === "path" ? [part.path] : []);
}
function assertLossless(text: string) {
const parts = parseFilePaths(text);
assert.equal(parts.map((part) => part.text).join(""), text);
let offset = 0;
for (const part of parts) {
assert.equal(part.start, offset);
assert.equal(text.slice(part.start, part.end), part.text);
offset = part.end;
}
assert.equal(offset, text.length);
}
describe("plain-text file path tokens", () => {
it("returns typed tokens with exact original text and UTF-16 offsets", () => {
const text = "📚 Read `/tmp/my notes.pdf` now.";
assert.deepEqual(parseFilePaths(text), [
{ kind: "text", text: "📚 Read ", start: 0, end: 8 },
{ kind: "path", text: "`/tmp/my notes.pdf`", path: "/tmp/my notes.pdf", start: 8, end: 27 },
{ kind: "text", text: " now.", start: 27, end: 32 },
]);
assert.deepEqual(parseFilePaths(""), []);
});
it("accepts rooted POSIX, drive, UNC and home paths without filesystem access", () => {
for (const path of [
"/tmp/notes.pdf", "/", "/does not exist/α ∂ 🧠.pdf", "//volume/file",
"C:\\Users\\Learner\\My notes.pdf", "d:/notes/測度.md", "C:\\",
"\\\\server\\share\\nested folder\\notes.pdf", "\\\\server\\share", "\\\\server/share/file.txt",
"~/Documents/notes.pdf", "~/", "/tmp/a\\b.txt", "/tmp/<>&\"'[]()#?!;:$*=|.txt",
]) {
const quoted = quoteFilePath(path);
assert.deepEqual(paths(quoted), [path]);
assertLossless(quoted);
}
});
it("recognizes manual and pasted quoted paths anywhere, including adjacent prose", () => {
const text = "word`/a`word (`~/b`) [`C:/c`](x) <`/d`> /not/quoted";
assert.deepEqual(paths(text), ["/a", "~/b", "C:/c", "/d"]);
assertLossless(text);
});
it("does not reinterpret ordinary prose, relative paths, URLs or code", () => {
for (const text of [
"Read /tmp/file.pdf and ~/notes", "`hello world`", "`a + b`", "`notes.pdf`", "`./notes.pdf`",
"`../notes.pdf`", "`C:notes.pdf`", "`~someone/notes.pdf`", "`file:///tmp/notes.pdf`",
"`https://example.test/a.pdf`", "`\\notes.pdf`", "`\\\\server`", "`const p = '/a';`",
"` /leading space`", "``code `/a` here``", "```\n/a\n```", "```\n`/a`\n```",
]) {
assert.deepEqual(paths(text), [], text);
assertLossless(text);
}
});
it("matches complete equal delimiter runs, never their partial boundaries", () => {
for (const text of ["`/a", "``/a`", "`/a``", "```/a``"]) {
assert.deepEqual(paths(text), []);
assertLossless(text);
}
// Other-width runs are legal filename characters, not partial closers.
assert.deepEqual(paths("``/a```/b``"), ["/a```/b"]);
assert.deepEqual(paths("`/a``/b`"), ["/a``/b"]);
assert.deepEqual(paths("``not code closed? `/a`"), ["/a"]);
assert.deepEqual(paths("```/a```"), ["/a"]);
});
it("preserves backticks with variable delimiters and one-space padding", () => {
assert.equal(quoteFilePath("/tmp/a`b.txt"), "``/tmp/a`b.txt``");
assert.equal(quoteFilePath("/tmp/a`"), "`` /tmp/a` ``");
assert.equal(quoteFilePath("/tmp/a```"), "```` /tmp/a``` ````");
for (const path of ["/tmp/a`", "/tmp/a``b`", "/tmp/`/```/``", "/tmp/a `", "/tmp/a ", "/tmp/ "]) {
const text = `before ${quoteFilePath(path)} after`;
assert.deepEqual(paths(text), [path]);
assertLossless(text);
}
assert.deepEqual(paths("`` /tmp/a` ``"), ["/tmp/a`"]);
assert.deepEqual(paths("` /tmp/a `"), ["/tmp/a "]);
assert.deepEqual(paths("` /tmp/a `"), [], "strip one padding space only");
});
it("rejects all C0/C1 controls, multiline paths and Unicode line separators", () => {
const controls = [
...Array.from({ length: 32 }, (_, i) => String.fromCharCode(i)),
...Array.from({ length: 33 }, (_, i) => String.fromCharCode(127 + i)),
"\u2028", "\u2029",
];
for (const control of controls) {
const path = `/tmp/a${control}b.pdf`;
assert.deepEqual(paths(`\`${path}\``), []);
assert.throws(() => quoteFilePath(path), TypeError);
}
for (const path of ["", "relative", "file:///tmp/a", "C:a", "\\\\server"]) {
assert.throws(() => quoteFilePath(path), TypeError);
}
});
it("round-trips many combinations of legal filename punctuation and whitespace", () => {
for (const piece of ["`", "``", " ``` ", " ", " ", "<script>", "[link](x)", "a\\b", "Ω📚"]) {
for (const suffix of ["", "`", "``", " ", ".pdf"]) {
const path = `/tmp/${piece}${suffix}`;
const quoted = quoteFilePath(path);
assert.deepEqual(paths(quoted), [path]);
assertLossless(quoted);
}
}
});
});
describe("filePathLabel", () => {
it("extracts basenames for each platform without normalizing the path", () => {
for (const [path, label] of [
["/tmp/a.pdf", "a.pdf"], ["~/測度 theory.md", "測度 theory.md"],
["C:\\Users\\a.pdf", "a.pdf"], ["C:/Users/a.pdf", "a.pdf"],
["\\\\server\\share\\a.pdf", "a.pdf"], ["/tmp/a\\b.txt", "a\\b.txt"],
["/tmp/folder/", "folder"], ["/", "/"], ["~/", "~/"], ["C:\\", "C:\\"],
["/tmp/a`b", "a`b"],
]) assert.equal(filePathLabel(path!), label);
});
});
+491
View File
@@ -0,0 +1,491 @@
import React, { act } from "react";
import assert from "node:assert/strict";
import { afterEach, test } from "node:test";
import { parseHTML } from "linkedom";
import { renderToStaticMarkup } from "react-dom/server";
import { PathInput, INSERT_FILE_PATHS_EVENT } from "../components/path-input.tsx";
import {
PATH_CARET_ATTRIBUTE, PATH_SOURCE_ATTRIBUTE, PathInputHistory, adjacentPathRange, atomicPathRange,
insertFilePaths, normalizePathEdit, normalizePathInputValue, pathInputOffset, pathInputPoint, readPathInput,
readPathSelection, renderPathInput, replacePathSelection, restorePathSelection,
} from "../components/path-input-dom.ts";
import { quoteFilePath } from "../src/core/file-paths.ts";
function editor() {
const { document } = parseHTML("<html><body><div id='editor'></div></body></html>");
return document.getElementById("editor")!;
}
test("DOM projection is lossless across whitespace, Unicode and all rooted path forms", () => {
const root = editor();
const paths = ["/a/notes one.pdf", "C:\\a\\notes.pdf", "\\\\host\\share\\sheet.csv", "~/notes`final`.pdf"];
const value = ` 👩🏽‍💻\t中\n${paths.map(quoteFilePath).join("\n\t")}\n \n`;
const hosts = renderPathInput(root, value);
assert.equal(hosts.length, paths.length);
for (const { host } of hosts) {
host.textContent = "abbreviated.pdf";
assert.equal(host.getAttribute("contenteditable"), "false");
}
assert.equal(readPathInput(root), value);
assert.equal(root.dataset.empty, "false");
renderPathInput(root, "");
assert.equal(readPathInput(root), "");
assert.equal(root.dataset.empty, "true");
});
test("logical offsets round-trip across text, adjacent chips, line breaks and reversed selections", () => {
const root = editor();
const value = `🙂A ${quoteFilePath("/one")} ${quoteFilePath("/two")}\nend`;
renderPathInput(root, value);
for (let offset = 0; offset <= value.length; offset++) {
const point = pathInputPoint(root, offset);
const mapped = pathInputOffset(root, point.node, point.offset)!;
const token = Array.from(root.querySelectorAll(`[${PATH_SOURCE_ATTRIBUTE}]`)).find((host) => {
const start = value.indexOf(host.getAttribute(PATH_SOURCE_ATTRIBUTE)!);
return offset > start && offset < start + host.getAttribute(PATH_SOURCE_ATTRIBUTE)!.length;
});
if (!token) assert.equal(mapped, offset, `offset ${offset}`);
else {
const raw = token.getAttribute(PATH_SOURCE_ATTRIBUTE)!;
const start = value.indexOf(raw);
assert.ok(mapped === start || mapped === start + raw.length);
}
}
assert.equal(pathInputOffset(root, root.ownerDocument.body, 0), null);
});
test("BR/block fallback never reads visual labels or normalizes ordinary spaces", () => {
const root = editor();
root.innerHTML = "<div> a </div><div><br></div><div>b</div>";
assert.equal(readPathInput(root), " a \n\nb");
root.innerHTML = "<br>";
assert.equal(readPathInput(root), "");
root.innerHTML = "a<br>b";
assert.equal(readPathInput(root), "a\nb");
});
test("trailing newline caret placeholder is presentation-only and logical end maps before it", () => {
const root = editor();
for (const value of ["\n", "\n\n", "one\n", "one\n\n", "one\n\n\n"]) {
renderPathInput(root, value);
assert.equal(root.querySelectorAll(`[${PATH_CARET_ATTRIBUTE}]`).length, 1);
assert.equal(root.lastElementChild?.tagName, "BR");
assert.equal(root.lastElementChild?.getAttribute("aria-hidden"), "true");
assert.equal(root.textContent, value);
assert.equal(readPathInput(root), value);
const point = pathInputPoint(root, value.length);
assert.equal(point.node, root);
assert.equal(point.offset, root.childNodes.length - 1);
assert.equal(pathInputOffset(root, point.node, point.offset), value.length);
assert.equal(pathInputOffset(root, root.lastChild!, 0), value.length);
}
for (const value of ["", "one", "one\ntwo"]) {
renderPathInput(root, value);
assert.equal(root.querySelector(`[${PATH_CARET_ATTRIBUTE}]`), null);
assert.equal(readPathInput(root), value);
}
});
test("range replacement/deletion is atomic and leaves neighboring whitespace untouched", () => {
const token = quoteFilePath("/course/notes.pdf");
const value = `a ${token} b`;
assert.deepEqual(atomicPathRange(value, { anchor: 5, focus: 1 }), { start: 1, end: 2 + token.length });
assert.equal(replacePathSelection(value, { anchor: 3, focus: 8 }, "").value, "a b");
assert.deepEqual(adjacentPathRange(value, { anchor: 2, focus: 2 }, "forward"), { anchor: 2, focus: 2 + token.length });
assert.deepEqual(adjacentPathRange(value, { anchor: 2 + token.length, focus: 2 + token.length }, "backward"), { anchor: 2, focus: 2 + token.length });
assert.equal(adjacentPathRange(value, { anchor: 1, focus: 1 }, "forward"), null);
});
test("file insertion uses full quoted sources at saved ranges with separating spaces", () => {
const next = insertFilePaths("readthis", { anchor: 4, focus: 4 }, ["/a b.pdf", "C:\\a\\x.txt"]);
assert.equal(next.value, "read `/a b.pdf` `C:\\a\\x.txt` this");
assert.equal(next.selection.anchor, next.value.length - 4);
assert.equal(insertFilePaths("a\n", { anchor: 2, focus: 2 }, ["~/x"]).value, "a\n`~/x`");
assert.throws(() => insertFilePaths("", { anchor: 0, focus: 0 }, ["relative.txt"]), TypeError);
});
test("owned history restores values and directional selections; a new edit discards redo", () => {
const history = new PathInputHistory();
const a = { value: "a", selection: { anchor: 1, focus: 0 } };
const b = { value: "ab", selection: { anchor: 2, focus: 2 } };
history.push(a);
assert.deepEqual(history.undo(b), a);
assert.deepEqual(history.redo(a), b);
history.undo(b);
history.push(a);
assert.equal(history.redo(b), null);
history.reset();
assert.equal(history.undo(b), null);
});
test("textbox exposes accessibility, placeholder, global-drop markers and requested sizing", () => {
const html = renderToStaticMarkup(<PathInput value="" onValueChange={() => {}} placeholder="Ask" aria-label="Message" defaultDropTarget className="min-h-24 max-h-64" />);
for (const pattern of [/role="textbox"/, /aria-multiline="true"/, /aria-label="Message"/, /data-file-path-input=""/, /data-file-path-default=""/, /data-placeholder="Ask"/, /min-h-24 max-h-64/]) assert.match(html, pattern);
});
test("SSR preserves the full escaped initial draft without waiting for client effects", () => {
const draft = " Restored <reasoning> & notes\n`/course/full notes.pdf`\n ";
const html = renderToStaticMarkup(<PathInput value={draft} onValueChange={() => assert.fail("SSR cannot emit changes")} placeholder="Reasoning" />);
const { document } = parseHTML(`<html><body>${html}</body></html>`);
const field = document.querySelector('[data-file-path-input]')!;
assert.equal(field.textContent, draft);
assert.equal(field.getAttribute("contenteditable") ?? field.getAttribute("contentEditable"), "true");
assert.equal(field.getAttribute("data-empty"), "false");
assert.equal(field.querySelector("reasoning"), null);
assert.match(html, /&lt;reasoning&gt;/);
const single = renderToStaticMarkup(<PathInput singleLine value={"one\r\ntwo"} onValueChange={() => {}} />);
assert.equal(parseHTML(`<html><body>${single}</body></html>`).document.body.textContent, "onetwo");
});
test("textbox accessible name falls back to placeholder and preserves explicit aria-label", () => {
const fallback = renderToStaticMarkup(<PathInput value="" onValueChange={() => {}} placeholder="Your reasoning" />);
assert.match(fallback, /aria-label="Your reasoning"/);
const explicit = renderToStaticMarkup(<PathInput value="" onValueChange={() => {}} placeholder="Your reasoning" aria-label="Custom answer" />);
assert.match(explicit, /aria-label="Custom answer"/);
assert.doesNotMatch(explicit, /aria-label="Your reasoning"/);
});
let cleanup: (() => Promise<void>) | undefined;
afterEach(async () => { await cleanup?.(); cleanup = undefined; });
/** linkedom has no Selection/default editing actions. The shim tests our offset
* contract, NOT browser caret navigation or the native IME implementation. */
async function fixture(initial: string, singleLine = false) {
const { window: win, document: doc } = parseHTML("<html><body><div id='root'></div><button id='elsewhere'></button></body></html>");
let active: Element = doc.body;
Object.defineProperty(doc, "activeElement", { get: () => active });
const selection = {
anchorNode: null as Node | null, anchorOffset: 0, focusNode: null as Node | null, focusOffset: 0,
setBaseAndExtent(anchorNode: Node, anchorOffset: number, focusNode: Node, focusOffset: number) {
Object.assign(this, { anchorNode, anchorOffset, focusNode, focusOffset });
},
};
Object.assign(doc, { getSelection: () => selection, oninput: null });
win.HTMLElement.prototype.focus = function () { active = this; };
const previous = new Map<string, PropertyDescriptor | undefined>();
for (const [name, value] of Object.entries({ React, window: win, document: doc, HTMLElement: win.HTMLElement, Element: win.Element, Event: win.Event, IS_REACT_ACT_ENVIRONMENT: true })) {
previous.set(name, Object.getOwnPropertyDescriptor(globalThis, name));
Object.defineProperty(globalThis, name, { configurable: true, writable: true, value });
}
const { createRoot } = await import("react-dom/client");
const reactRoot = createRoot(doc.getElementById("root")!);
const ref = React.createRef<HTMLDivElement>();
let value = initial;
const writes: string[] = [];
let keys = 0;
function draw() {
reactRoot.render(<PathInput ref={ref} singleLine={singleLine} value={value} onValueChange={(next) => { writes.push(next); value = next; draw(); }} onKeyDown={() => { keys++; }} />);
}
await act(async () => { draw(); });
cleanup = async () => {
await act(async () => { reactRoot.unmount(); });
for (const [name, descriptor] of previous) {
if (descriptor) Object.defineProperty(globalThis, name, descriptor);
else delete (globalThis as Record<string, unknown>)[name];
}
};
const root = ref.current!;
root.focus();
function select(anchor: number, focus = anchor) {
restorePathSelection(root, { anchor, focus });
doc.dispatchEvent(new win.Event("selectionchange"));
}
async function event(type: string, properties: Record<string, unknown> = {}) {
const event = new win.Event(type, { bubbles: true, cancelable: true });
Object.assign(event, properties);
await act(async () => { root.dispatchEvent(event); });
return event;
}
return {
root, doc, select, event, writes, selection,
get value() { return value; }, get keys() { return keys; },
async external(next: string) { await act(async () => { value = next; draw(); }); },
async nativeText(next: string, inputType = "insertText") {
await event("beforeinput", { inputType, isComposing: false });
root.replaceChildren(doc.createTextNode(next));
select(next.length);
await event("input", { inputType });
},
};
}
test("native typing, token insertion/deletion and undo/redo share one history", async () => {
const f = await fixture("a");
f.select(1);
await f.nativeText("ab");
await f.event(INSERT_FILE_PATHS_EVENT, { detail: ["/notes.pdf"] });
assert.equal(f.value, "ab `/notes.pdf`");
assert.match(f.root.textContent!, /notes.pdf/);
await f.event("keydown", { key: "Backspace" });
assert.equal(f.value, "ab ");
await f.event("keydown", { key: "z", metaKey: true });
assert.equal(f.value, "ab `/notes.pdf`");
await f.event("keydown", { key: "z", metaKey: true });
assert.equal(f.value, "ab");
await f.event("keydown", { key: "z", metaKey: true });
assert.equal(f.value, "a");
await f.event("beforeinput", { inputType: "historyRedo" });
assert.equal(f.value, "ab");
});
test("immutable SSR fallback never stomps native edits, controlled updates, chips or reset", async () => {
const initial = "Restored draft `/course/original.pdf`";
const f = await fixture(initial);
assert.equal(readPathInput(f.root), initial);
assert.equal(f.root.querySelectorAll(`[${PATH_SOURCE_ATTRIBUTE}]`).length, 1);
f.select(initial.length);
await f.nativeText("New typed draft");
assert.equal(readPathInput(f.root), "New typed draft");
assert.equal(f.root.textContent, "New typed draft");
await f.external("Controlled `/course/replaced.pdf`");
assert.equal(readPathInput(f.root), "Controlled `/course/replaced.pdf`");
assert.equal(f.root.querySelectorAll(`[${PATH_SOURCE_ATTRIBUTE}]`).length, 1);
assert.doesNotMatch(f.root.textContent!, /Restored draft/);
await f.external("");
await f.external(""); // Re-render again with the same initial fallback child prop.
assert.equal(readPathInput(f.root), "");
assert.equal(f.root.textContent, "");
assert.equal(f.root.dataset.empty, "true");
});
test("external dictation append/reset and drop at an unfocused saved selection", async () => {
const f = await fixture("one two");
f.select(3);
f.doc.getElementById("elsewhere")!.focus();
await f.event(INSERT_FILE_PATHS_EVENT, { detail: ["/notes one.pdf"] });
assert.equal(f.value, "one `/notes one.pdf` two");
assert.equal(f.doc.activeElement, f.root);
assert.equal(f.keys, 0, "staging must not submit");
f.select(f.value.length);
await f.external(`${f.value}\nspoken words`);
assert.equal(readPathInput(f.root), f.value);
await f.event("keydown", { key: "z", ctrlKey: true });
assert.equal(f.value, "one `/notes one.pdf` two");
await f.external("");
assert.equal(readPathInput(f.root), "");
await f.event("keydown", { key: "z", ctrlKey: true });
assert.equal(f.value, "");
});
test("plaintext paste ignores HTML and copy/cut use full token source in either direction", async () => {
const source = "read `/deep/course/notes.pdf` now";
const f = await fixture(source);
f.select(source.length - 4, 5);
const clipboard = new Map<string, string>();
const clipboardData = { setData: (type: string, text: string) => clipboard.set(type, text), getData: (type: string) => type === "text/plain" ? "<img src=x onerror=alert(1)>\n中" : "<b>unsafe HTML</b>" };
await f.event("copy", { clipboardData });
assert.equal(clipboard.get("text/plain"), "`/deep/course/notes.pdf`");
await f.event("cut", { clipboardData });
assert.equal(f.value, "read now");
f.select(5);
await f.event("paste", { clipboardData });
assert.equal(f.value, "read <img src=x onerror=alert(1)>\n中 now");
assert.equal(f.root.querySelector("img"), null);
assert.equal(readPathInput(f.root), f.value);
});
test("image/file-only paste prevents native insertion without deleting selected text or chips", async () => {
const source = "plain `/course/notes.pdf` end";
const f = await fixture(source);
const clipboardData = { types: ["Files", "image/png"], getData: () => "" };
for (const [anchor, focus] of [[0, 5], [6, source.length - 4]]) {
f.select(anchor!, focus!);
const paste = await f.event("paste", { clipboardData });
assert.equal(paste.defaultPrevented, true);
assert.equal(f.value, source);
assert.equal(readPathInput(f.root), source);
}
assert.deepEqual(f.writes, []);
assert.equal(f.root.querySelectorAll(`[${PATH_SOURCE_ATTRIBUTE}]`).length, 1);
});
test("empty clipboard text stays a no-op before, during and after IME with a chip selected", async () => {
const source = "a `/course/notes.pdf` z";
const f = await fixture(source);
f.select(2, source.length - 2);
const clipboardData = { getData: () => "", types: ["text/html"] };
await f.event("paste", { clipboardData });
await f.event("compositionstart");
const during = await f.event("paste", { clipboardData });
assert.equal(during.defaultPrevented, true);
assert.equal(readPathInput(f.root), source);
await f.event("compositionend");
assert.equal(readPathInput(f.root), source, "no empty pending paste can remove the selected chip");
await f.event("paste", { clipboardData });
assert.equal(f.value, source);
assert.deepEqual(f.writes, []);
});
test("Shift+Delete delegates to cut, preserving full-source clipboard and deleting only once", async () => {
const f = await fixture("plain`/next.pdf`");
for (const [source, selected] of [["plain`/next.pdf`", "plain"], ["`/course/notes.pdf` `/next.pdf`", "`/course/notes.pdf` "]] as const) {
await f.external(source);
f.select(0, selected.length);
const key = await f.event("keydown", { key: "Delete", shiftKey: true });
assert.equal(key.defaultPrevented, false, "native Cut shortcut must reach the browser");
assert.equal(f.value, source, "keydown alone must not delete the selection");
let copied = "";
const cut = await f.event("cut", { clipboardData: { setData: (type: string, value: string) => { assert.equal(type, "text/plain"); copied = value; } } });
assert.equal(cut.defaultPrevented, true);
assert.equal(copied, selected);
assert.equal(f.value, "`/next.pdf`");
const beforeInput = await f.event("beforeinput", { inputType: "deleteByCut" });
assert.equal(beforeInput.defaultPrevented, true);
assert.equal(f.value, "`/next.pdf`", "a trailing deleteByCut must not delete the neighboring chip");
}
});
test("copying even a partial visual chip label copies its complete source", async () => {
const f = await fixture("read `/deep/course/notes.pdf` now");
const label = f.root.querySelector(`[${PATH_SOURCE_ATTRIBUTE}] [role=img] span`)!.firstChild!;
f.selection.setBaseAndExtent(label, 1, label, 3);
f.doc.dispatchEvent(new window.Event("selectionchange"));
let copied = "";
await f.event("copy", { clipboardData: { setData: (_type: string, value: string) => { copied = value; } } });
assert.equal(copied, "`/deep/course/notes.pdf`");
});
test("composition keeps native DOM untouched, emits once, and suppresses consumer Enter", async () => {
const f = await fixture("hello ");
f.select(6);
await f.event("compositionstart");
f.root.replaceChildren(f.doc.createTextNode("hello 中"));
f.select(7);
await f.event("input", { isComposing: true });
await f.event("keydown", { key: "Enter", keyCode: 229, isComposing: true });
assert.equal(f.writes.length, 0);
assert.equal(f.keys, 0);
assert.equal(readPathInput(f.root), "hello 中");
await f.event("compositionend");
assert.deepEqual(f.writes, ["hello 中"]);
await f.event("keydown", { key: "z", metaKey: true });
assert.equal(f.value, "hello ");
});
test("Enter inserts a literal newline and range deletion spans text and complete chips", async () => {
const f = await fixture("a `/one.pdf` b");
f.select(f.value.length);
await f.event("beforeinput", { inputType: "insertParagraph" });
assert.equal(f.value, "a `/one.pdf` b\n");
f.select(1, f.value.length - 1);
await f.event("keydown", { key: "Delete" });
assert.equal(f.value, "a\n");
assert.equal(readPathInput(f.root), "a\n");
});
test("multiple trailing newlines preserve selection and survive native Backspace plus undo/redo", async () => {
const f = await fixture("one\n\n");
f.select(f.value.length, 1);
assert.deepEqual(readPathSelection(f.root), { anchor: 5, focus: 1 });
f.select(f.value.length);
assert.equal(f.selection.focusNode, f.root);
assert.equal(f.root.lastElementChild?.hasAttribute(PATH_CARET_ATTRIBUTE), true);
// linkedom has no default Backspace action; model the native text deletion.
await f.nativeText("one\n", "deleteContentBackward");
assert.equal(f.value, "one\n");
assert.equal(readPathInput(f.root), f.value);
assert.equal(f.root.querySelectorAll(`[${PATH_CARET_ATTRIBUTE}]`).length, 1);
await f.event("keydown", { key: "z", metaKey: true });
assert.equal(f.value, "one\n\n");
assert.deepEqual(readPathSelection(f.root), { anchor: 5, focus: 5 });
assert.equal(f.root.querySelectorAll(`[${PATH_CARET_ATTRIBUTE}]`).length, 1);
await f.event("keydown", { key: "z", metaKey: true, shiftKey: true });
assert.equal(f.value, "one\n");
assert.deepEqual(readPathSelection(f.root), { anchor: 4, focus: 4 });
await f.external("");
assert.equal(f.root.dataset.empty, "true");
assert.equal(f.root.querySelector(`[${PATH_CARET_ATTRIBUTE}]`), null);
});
test("single-line mode never adds a trailing newline caret placeholder", async () => {
const f = await fixture("one\n\n", true);
assert.equal(readPathInput(f.root), "one");
assert.equal(f.root.querySelector(`[${PATH_CARET_ATTRIBUTE}]`), null);
});
test("paste during IME stays plaintext and is deferred until composition finishes", async () => {
const f = await fixture("");
f.select(0);
await f.event("compositionstart");
f.root.replaceChildren(f.doc.createTextNode("中"));
f.select(1);
await f.event("input", { isComposing: true });
assert.equal(f.root.dataset.empty, "false", "placeholder hides without normalizing the composing DOM");
const paste = await f.event("paste", { clipboardData: { getData: () => "<b>plain</b>" } });
assert.equal(paste.defaultPrevented, true);
assert.equal(readPathInput(f.root), "中");
assert.equal(f.writes.length, 0);
await f.event("compositionend");
assert.equal(f.value, "中<b>plain</b>");
assert.equal(f.root.querySelector("b"), null);
});
test("single-line normalization strips only CR/LF and remaps directional UTF-16 selections", () => {
const source = "🙂\r\n中\t x\ny\r";
assert.equal(normalizePathInputValue(source, false), source);
assert.equal(normalizePathInputValue(source, true), "🙂中\t xy");
assert.deepEqual(normalizePathEdit({ value: source, selection: { anchor: source.length, focus: 4 } }, true), {
value: "🙂中\t xy", selection: { anchor: 7, focus: 2 },
});
});
test("single-line mode exposes native input accessibility and horizontal, nonwrapping layout", () => {
const html = renderToStaticMarkup(<PathInput value="" onValueChange={() => {}} singleLine className="h-8" />);
assert.match(html, /aria-multiline="false"/);
assert.match(html, /whitespace-pre /);
assert.match(html, /overflow-x-auto/);
assert.match(html, /overflow-y-hidden/);
assert.doesNotMatch(html, /whitespace-pre-wrap/);
assert.match(html, /h-8/);
});
test("single-line initial/external assignment sanitizes silently; native edits and paste emit sanitized values", async () => {
const f = await fixture("one\r\ntwo\n", true);
assert.equal(readPathInput(f.root), "onetwo");
assert.deepEqual(f.writes, []);
await f.external("hello\r\nworld");
assert.equal(readPathInput(f.root), "helloworld");
assert.deepEqual(f.writes, []);
f.select(10);
await f.nativeText("ab\r\nc");
assert.equal(f.value, "abc");
assert.equal(readPathInput(f.root), "abc");
assert.equal(pathInputOffset(f.root, f.selection.focusNode!, f.selection.focusOffset), 3);
await f.event("paste", { clipboardData: { getData: () => "x\r\n y\n" } });
assert.equal(f.value, "abcx y");
assert.deepEqual(f.writes, ["abc", "abcx y"]);
});
test("single-line Enter reaches its consumer but cannot insert a newline", async () => {
const f = await fixture("answer", true);
f.select(6);
await f.event("keydown", { key: "Enter", shiftKey: true });
assert.equal(f.keys, 1);
const paragraph = await f.event("beforeinput", { inputType: "insertParagraph" });
const lineBreak = await f.event("beforeinput", { inputType: "insertLineBreak" });
assert.equal(paragraph.defaultPrevented, true);
assert.equal(lineBreak.defaultPrevented, true);
assert.equal(f.value, "answer");
assert.deepEqual(f.writes, []);
await f.event(INSERT_FILE_PATHS_EVENT, { detail: ["/course/notes.pdf"] });
assert.equal(f.value, "answer `/course/notes.pdf`");
});
test("single-line IME sanitizes after completion, and queued external assignments do not synthesize changes", async () => {
const f = await fixture("a", true);
f.select(1);
await f.event("compositionstart");
f.root.replaceChildren(f.doc.createTextNode("a中\r\n文"));
f.select(5);
await f.event("input", { isComposing: true });
assert.deepEqual(f.writes, []);
await f.event("compositionend");
assert.equal(f.value, "a中文");
assert.equal(readPathInput(f.root), "a中文");
await f.event("compositionstart");
await f.external("external\r\nvalue");
assert.equal(readPathInput(f.root), "a中文", "assignment waits for native composition to finish");
await f.event("compositionend");
assert.equal(readPathInput(f.root), "externalvalue");
assert.deepEqual(f.writes, ["a中文"]);
});
+1 -1
View File
@@ -96,7 +96,7 @@ describe("question submission draft restoration", () => {
it("restores an ask's custom answer and a free-text answer", () => {
const custom = renderToStaticMarkup(<QuestionDock toolName="ask_user_question" toolCallId="ask1" input={{ question: "Fixture?", options: [{ label: "Listed answer" }, { label: "Another answer" }], multiSelect: true }} expanded onExpand={() => {}} onSubmit={() => {}} initialDraft={draft} />);
assert.ok(custom.includes('value="preserved custom answer"'));
assert.match(custom, /role="textbox"[^>]*aria-multiline="false"[^>]*>preserved custom answer<\/div>/);
assert.match(custom, /aria-checked="true" data-testid="other-option"/);
const text = renderToStaticMarkup(<QuestionDock toolName="ask_user_question" toolCallId="ask2" input={{ question: "Fixture?" }} expanded onExpand={() => {}} onSubmit={() => {}} initialDraft={draft} />);
assert.ok(text.includes("preserved free-text answer"));
+1 -1
View File
@@ -100,7 +100,7 @@ describe("DraftShell — the sessionless surface", () => {
const html = renderToStaticMarkup(<DraftShell busy={false} text="keep all my text" onChange={() => {}} onSend={() => {}} startError />);
assert.ok(html.includes('role="alert"'));
assert.ok(html.includes(OPENING_PROMPT_FAILURE));
assert.match(html, /<textarea[^>]*>keep all my text<\/textarea>/);
assert.match(html, /<div[^>]*role="textbox"[^>]*contentEditable="true"[^>]*>keep all my text<\/div>/);
});
it("renders the composer blob, the stats bar, and the fresh-chat empty state", () => {
const html = renderToStaticMarkup(
+2 -1
View File
@@ -81,13 +81,14 @@ describe("sessionFindRanges — the displayed-text DOM seam", () => {
assert.ok((ranges[0]!.startContainer as Text).data.includes("session"));
});
it("hidden subtrees never match: hidden attr, menus, katex MathML duplicate, textareas (display:none is browser-verified)", () => {
it("hidden subtrees never match: hidden attr, menus, katex MathML duplicate, draft editors (display:none is browser-verified)", () => {
mount(`<div data-session-find-root="">
<p>needle visible</p>
<p hidden>needle hidden-attr</p>
<div data-lh-menu><p>needle menu</p></div>
<span class="katex-mathml">needle mathml</span>
<textarea>needle draft</textarea>
<div contenteditable="true" data-file-path-input>needle path draft <span contenteditable="false">notes.pdf</span></div>
</div>`);
const ranges = sessionFindRanges(document.body as unknown as HTMLElement, "needle");
assert.equal(ranges.length, 1);