feat: attach desktop file paths as editable learner chips
This commit is contained in:
@@ -4,6 +4,7 @@ import { useState } from "react";
|
|||||||
import { ConfidenceRail } from "@/components/confidence-rail";
|
import { ConfidenceRail } from "@/components/confidence-rail";
|
||||||
import { GlyphMark } from "@/components/ui/glyph-mark";
|
import { GlyphMark } from "@/components/ui/glyph-mark";
|
||||||
import { ThinkingBlock } from "@/components/thinking-block";
|
import { ThinkingBlock } from "@/components/thinking-block";
|
||||||
|
import { PathText } from "@/components/file-path-chip";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { ToolContentResponse } from "@/components/ai-elements/message";
|
import { ToolContentResponse } from "@/components/ai-elements/message";
|
||||||
import type { QuestionAnswerData } from "@/src/core";
|
import type { QuestionAnswerData } from "@/src/core";
|
||||||
@@ -153,13 +154,13 @@ export function AnsweredCard({ answer: savedAnswer, input }: { answer: QuestionA
|
|||||||
data-testid="answered-other"
|
data-testid="answered-other"
|
||||||
className="flex items-center gap-2 rounded-lg border border-primary bg-accent px-3 py-1.5"
|
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>
|
||||||
)}
|
)}
|
||||||
</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
|
{/* The confidence record (owner round 6): the slider's rail, disc
|
||||||
removed — the missing handle itself says "this is a record, not a
|
removed — the missing handle itself says "this is a record, not a
|
||||||
|
|||||||
+7
-6
@@ -14,6 +14,7 @@ import {
|
|||||||
ConversationScrollButton,
|
ConversationScrollButton,
|
||||||
} from "@/components/ai-elements/conversation";
|
} from "@/components/ai-elements/conversation";
|
||||||
import { Message, MessageContent, MessageResponse } from "@/components/ai-elements/message";
|
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 { Reasoning, ReasoningContent, ReasoningTrigger } from "@/components/ai-elements/reasoning";
|
||||||
import { Shimmer } from "@/components/ai-elements/shimmer";
|
import { Shimmer } from "@/components/ai-elements/shimmer";
|
||||||
import { BrailleSpinner } from "@/components/braille-spinner";
|
import { BrailleSpinner } from "@/components/braille-spinner";
|
||||||
@@ -687,10 +688,10 @@ export function Chat({
|
|||||||
const cycleChatFields = useCallback((): readonly HTMLElement[] => {
|
const cycleChatFields = useCallback((): readonly HTMLElement[] => {
|
||||||
const pick = (sel: string) => Array.from(document.querySelectorAll<HTMLElement>(sel));
|
const pick = (sel: string) => Array.from(document.querySelectorAll<HTMLElement>(sel));
|
||||||
return [
|
return [
|
||||||
...pick('[data-testid="reading-thinking-field"] textarea'),
|
...pick('[data-testid="reading-thinking-field"] [data-file-path-input]'),
|
||||||
...pick('[data-testid="question-dock"] textarea'),
|
...pick('[data-testid="question-dock"] [data-file-path-input]'),
|
||||||
...pick('[data-testid="practice-dock"] textarea'),
|
...pick('[data-testid="practice-dock"] [data-file-path-input]'),
|
||||||
...pick('[data-testid="composer"] textarea'),
|
...pick('[data-testid="composer"] [data-file-path-input]'),
|
||||||
];
|
];
|
||||||
}, []);
|
}, []);
|
||||||
useFieldCycle(cycleChatFields);
|
useFieldCycle(cycleChatFields);
|
||||||
@@ -705,7 +706,7 @@ export function Chat({
|
|||||||
* composer: with a question pending the Composer is unmounted, the ref
|
* composer: with a question pending the Composer is unmounted, the ref
|
||||||
* is null, and the thinking field is left alone by design. */
|
* is null, and the thinking field is left alone by design. */
|
||||||
const dictationWasActiveRef = useRef(false);
|
const dictationWasActiveRef = useRef(false);
|
||||||
const composerInputRef = useRef<HTMLTextAreaElement>(null);
|
const composerInputRef = useRef<HTMLDivElement>(null);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (dictation.active) {
|
if (dictation.active) {
|
||||||
dictationWasActiveRef.current = true;
|
dictationWasActiveRef.current = true;
|
||||||
@@ -1507,7 +1508,7 @@ export function Chat({
|
|||||||
rail as the thinking rows — one quote geometry. */}
|
rail as the thinking rows — one quote geometry. */}
|
||||||
<div className="font-medium uppercase tracking-wide text-muted-foreground">You</div>
|
<div className="font-medium uppercase tracking-wide text-muted-foreground">You</div>
|
||||||
<div className="lh-prose whitespace-pre-wrap text-foreground">
|
<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>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
import { ArrowRightIcon, CornerDownLeftIcon, SquareIcon } from "lucide-react";
|
import { ArrowRightIcon, CornerDownLeftIcon, SquareIcon } from "lucide-react";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { Hint } from "@/components/ui/hint";
|
import { Hint } from "@/components/ui/hint";
|
||||||
|
import { PathInput } from "@/components/path-input";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The chat composer — content row ONLY (the persistent rounded shell lives in
|
* The chat composer — content row ONLY (the persistent rounded shell lives in
|
||||||
@@ -59,7 +60,7 @@ export function Composer({
|
|||||||
autoFocus?: boolean;
|
autoFocus?: boolean;
|
||||||
/** The page's handle to the textarea — the dictation-end focus (owner,
|
/** 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. */
|
* 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
|
/** Escape while a dictation runs stops it (STT wave, 2026-09-17). No-op
|
||||||
* when absent — Esc otherwise means nothing in the composer. */
|
* when absent — Esc otherwise means nothing in the composer. */
|
||||||
onEscape?: () => void;
|
onEscape?: () => void;
|
||||||
@@ -90,10 +91,11 @@ export function Composer({
|
|||||||
act();
|
act();
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<textarea
|
<PathInput
|
||||||
ref={inputRef}
|
ref={inputRef}
|
||||||
value={value}
|
value={value}
|
||||||
onChange={(e) => onChange(e.target.value)}
|
onValueChange={onChange}
|
||||||
|
defaultDropTarget
|
||||||
onKeyDown={(e) => {
|
onKeyDown={(e) => {
|
||||||
// Esc ends dictation before anything else Esc might someday mean
|
// Esc ends dictation before anything else Esc might someday mean
|
||||||
// here (the dictate decisions: click-again or Esc to stop).
|
// here (the dictate decisions: click-again or Esc to stop).
|
||||||
@@ -115,7 +117,6 @@ export function Composer({
|
|||||||
}}
|
}}
|
||||||
placeholder={placeholder}
|
placeholder={placeholder}
|
||||||
autoFocus={autoFocus}
|
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"
|
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
|
{/* The button follows the text (owner report 2026-10-03): while
|
||||||
|
|||||||
@@ -199,6 +199,17 @@ export function rmsToMeter(rms: number): number {
|
|||||||
* window, one pending question, one target at a time.
|
* window, one pending question, one target at a time.
|
||||||
*/
|
*/
|
||||||
let questionTarget: ((text: string) => void) | null = null;
|
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
|
/* The finisher registry: submit paths FAR from the hook's owner (the
|
||||||
* pending question's dock) need finish() without a prop bridge. Same
|
* 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. */
|
* retarget (composer↔question) is honored on the very next final. */
|
||||||
const appendRef = useRef(append);
|
const appendRef = useRef(append);
|
||||||
appendRef.current = 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
|
// Live machinery (non-render values). The MediaStream tracks stop and
|
||||||
// the per-run nodes disconnect on teardown; the socket is recreated per
|
// the per-run nodes disconnect on teardown; the socket is recreated per
|
||||||
|
|||||||
@@ -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>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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
@@ -13,6 +13,7 @@ import { useFieldCycle } from "@/components/field-cycle";
|
|||||||
import { SidebarToggle } from "@/components/sidebar";
|
import { SidebarToggle } from "@/components/sidebar";
|
||||||
import { StatsBar } from "@/components/stats-bar";
|
import { StatsBar } from "@/components/stats-bar";
|
||||||
import { OPENING_PROMPT_FAILURE } from "@/components/opening-prompt";
|
import { OPENING_PROMPT_FAILURE } from "@/components/opening-prompt";
|
||||||
|
import { PathInput } from "@/components/path-input";
|
||||||
import { SessionStarter } from "@/components/start-session";
|
import { SessionStarter } from "@/components/start-session";
|
||||||
import type { TurnUsage } from "@/src/core";
|
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
|
/* 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
|
* the keyboard from the stop. Falling edge only; this surface's only
|
||||||
* dictation target IS this textarea, so no case-split is needed. */
|
* 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
|
// Tab navigates INTO the topic composer (owner, keyboard round
|
||||||
// 2026-10-06): the only text field on this surface — and home's
|
// 2026-10-06): the only text field on this surface — and home's
|
||||||
// composer is its OWN textarea, not the shared Composer component,
|
// composer is its OWN textarea, not the shared Composer component,
|
||||||
@@ -248,13 +249,14 @@ export function Home({
|
|||||||
void start();
|
void start();
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<textarea
|
<PathInput
|
||||||
ref={topicInputRef}
|
ref={topicInputRef}
|
||||||
value={topic}
|
value={topic}
|
||||||
onChange={(e) => {
|
onValueChange={(value) => {
|
||||||
topicRef.current = e.target.value;
|
topicRef.current = value;
|
||||||
setTopic(e.target.value);
|
setTopic(value);
|
||||||
}}
|
}}
|
||||||
|
defaultDropTarget
|
||||||
onKeyDown={(e) => {
|
onKeyDown={(e) => {
|
||||||
// Esc ends dictation (the dictate decisions: click-again or
|
// Esc ends dictation (the dictate decisions: click-again or
|
||||||
// Esc) — stopPropagation claims it (keyboard wave A) so
|
// 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?"}
|
placeholder={mode === "practice" ? "What do you want to practice?" : "What do you want to learn?"}
|
||||||
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"
|
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
|
{/* No tooltip on the start square — the ↵ glyph is the
|
||||||
|
|||||||
@@ -114,7 +114,7 @@ export function DraftShell({
|
|||||||
// Tab navigates INTO the composer (owner, keyboard round 2026-10-06):
|
// Tab navigates INTO the composer (owner, keyboard round 2026-10-06):
|
||||||
// the only text field on this surface — the cycle's whole chain.
|
// the only text field on this surface — the cycle's whole chain.
|
||||||
const cycleComposer = useCallback((): readonly HTMLElement[] =>
|
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);
|
useFieldCycle(cycleComposer);
|
||||||
const dictation = useDictation({
|
const dictation = useDictation({
|
||||||
@@ -138,7 +138,7 @@ export function DraftShell({
|
|||||||
};
|
};
|
||||||
/* Run end focuses the composer (owner, 2026-10-01): the hands are on
|
/* Run end focuses the composer (owner, 2026-10-01): the hands are on
|
||||||
* the keyboard from the stop — falling edge of `active` only. */
|
* the keyboard from the stop — falling edge of `active` only. */
|
||||||
const composerInputRef = useRef<HTMLTextAreaElement>(null);
|
const composerInputRef = useRef<HTMLDivElement>(null);
|
||||||
const dictationWasActiveRef = useRef(false);
|
const dictationWasActiveRef = useRef(false);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (dictation.active) {
|
if (dictation.active) {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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))}
|
||||||
|
</>;
|
||||||
|
});
|
||||||
@@ -6,6 +6,7 @@ import { ToolContentResponse } from "@/components/ai-elements/message";
|
|||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { joinDictation, useDictationTarget } from "@/components/dictation";
|
import { joinDictation, useDictationTarget } from "@/components/dictation";
|
||||||
import { useEnterSubmits } from "@/components/enter-submits";
|
import { useEnterSubmits } from "@/components/enter-submits";
|
||||||
|
import { PathInput } from "@/components/path-input";
|
||||||
import { cn } from "@/lib/utils";
|
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">
|
<div className="px-2.5 pt-2 text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
||||||
YOUR THINKING
|
YOUR THINKING
|
||||||
</div>
|
</div>
|
||||||
<textarea
|
<PathInput
|
||||||
value={thinking}
|
value={thinking}
|
||||||
onChange={(e) => {
|
onValueChange={(value) => {
|
||||||
thinkingRef.current = e.target.value;
|
thinkingRef.current = value;
|
||||||
setThinking(e.target.value);
|
setThinking(value);
|
||||||
}}
|
}}
|
||||||
onKeyDown={(e) => {
|
onKeyDown={(e) => {
|
||||||
// Esc ends dictation — stopPropagation claims it (the
|
// 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."
|
placeholder="Work here, think out loud."
|
||||||
rows={4}
|
|
||||||
className={cn(
|
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",
|
"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",
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { ArrowDownIcon, ChevronRightIcon, PlayIcon } from "lucide-react";
|
|||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { finishDictation, isDictationActive, joinDictation, useDictationTarget } from "@/components/dictation";
|
import { finishDictation, isDictationActive, joinDictation, useDictationTarget } from "@/components/dictation";
|
||||||
import { useEnterSubmits } from "@/components/enter-submits";
|
import { useEnterSubmits } from "@/components/enter-submits";
|
||||||
|
import { PathInput } from "@/components/path-input";
|
||||||
import { ToolContentResponse } from "@/components/ai-elements/message";
|
import { ToolContentResponse } from "@/components/ai-elements/message";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import {
|
import {
|
||||||
@@ -439,8 +440,8 @@ function QuestionDockForm({
|
|||||||
expanded — collapsed, the bar's native Enter expands it. */
|
expanded — collapsed, the bar's native Enter expands it. */
|
||||||
const [kbdIdx, setKbdIdx] = useState<number | null>(null);
|
const [kbdIdx, setKbdIdx] = useState<number | null>(null);
|
||||||
const rootRef = useRef<HTMLDivElement>(null);
|
const rootRef = useRef<HTMLDivElement>(null);
|
||||||
const otherInputRef = useRef<HTMLInputElement>(null);
|
const otherInputRef = useRef<HTMLDivElement>(null);
|
||||||
const thinkingRef = useRef<HTMLTextAreaElement>(null);
|
const thinkingRef = useRef<HTMLDivElement>(null);
|
||||||
// One row past the options is the trailing row: quiz → don't-know,
|
// One row past the options is the trailing row: quiz → don't-know,
|
||||||
// ask → Other (a question never has both). Text mode (ask/explain)
|
// ask → Other (a question never has both). Text mode (ask/explain)
|
||||||
// has no rows — the composer IS the answer surface.
|
// has no rows — the composer IS the answer surface.
|
||||||
@@ -473,7 +474,7 @@ function QuestionDockForm({
|
|||||||
const focusRow = (idx: number) => {
|
const focusRow = (idx: number) => {
|
||||||
const row = rootRef.current?.querySelector(`[data-kbdrow="${idx}"]`) as HTMLElement | null;
|
const row = rootRef.current?.querySelector(`[data-kbdrow="${idx}"]`) as HTMLElement | null;
|
||||||
if (!row) return;
|
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();
|
el.focus();
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -696,11 +697,12 @@ function QuestionDockForm({
|
|||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</span>
|
</span>
|
||||||
<input
|
<PathInput
|
||||||
ref={otherInputRef}
|
ref={otherInputRef}
|
||||||
|
singleLine
|
||||||
value={other}
|
value={other}
|
||||||
onChange={(e) => {
|
onValueChange={(value) => {
|
||||||
setOther(e.target.value);
|
setOther(value);
|
||||||
// Typing selects the row — displacing in
|
// Typing selects the row — displacing in
|
||||||
// single-select, alongside the siblings in multi.
|
// single-select, alongside the siblings in multi.
|
||||||
setSel((prev) => (prev.otherSelected ? prev : toggleOther(selectMode, prev)));
|
setSel((prev) => (prev.otherSelected ? prev : toggleOther(selectMode, prev)));
|
||||||
@@ -851,11 +853,11 @@ function QuestionDockForm({
|
|||||||
{qd.mode === "text" ? "Your answer" : "YOUR THINKING"}
|
{qd.mode === "text" ? "Your answer" : "YOUR THINKING"}
|
||||||
</div>
|
</div>
|
||||||
{qd.mode === "text" ? (
|
{qd.mode === "text" ? (
|
||||||
<textarea
|
<PathInput
|
||||||
value={text}
|
value={text}
|
||||||
onChange={(e) => {
|
onValueChange={(value) => {
|
||||||
answerTextRef.current = e.target.value;
|
answerTextRef.current = value;
|
||||||
setText(e.target.value);
|
setText(value);
|
||||||
}}
|
}}
|
||||||
onKeyDown={(e) => {
|
onKeyDown={(e) => {
|
||||||
// Esc ends dictation (the dictate decisions: click-again
|
// Esc ends dictation (the dictate decisions: click-again
|
||||||
@@ -875,16 +877,15 @@ function QuestionDockForm({
|
|||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
placeholder="Type your answer…"
|
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"
|
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}
|
ref={thinkingRef}
|
||||||
value={thinking}
|
value={thinking}
|
||||||
onChange={(e) => {
|
onValueChange={(value) => {
|
||||||
thinkingTextRef.current = e.target.value;
|
thinkingTextRef.current = value;
|
||||||
setThinking(e.target.value);
|
setThinking(value);
|
||||||
}}
|
}}
|
||||||
onKeyDown={(e) => {
|
onKeyDown={(e) => {
|
||||||
// Esc ends dictation (the dictate decisions: click-again
|
// Esc ends dictation (the dictate decisions: click-again
|
||||||
@@ -905,7 +906,6 @@ function QuestionDockForm({
|
|||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
placeholder="Why this answer? Whatever comes to mind."
|
placeholder="Why this answer? Whatever comes to mind."
|
||||||
rows={1}
|
|
||||||
className={cn(
|
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",
|
"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,
|
// Phase-scaled prominence: teaching invites depth,
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import { useCallback } from "react";
|
import { useCallback } from "react";
|
||||||
import { joinDictation, useDictationTarget } from "@/components/dictation";
|
import { joinDictation, useDictationTarget } from "@/components/dictation";
|
||||||
|
import { PathInput } from "@/components/path-input";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The reading-thinking field (owner design 2026-10-03, "two questions per
|
* 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">
|
<div className="pb-1 text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
||||||
Thinking while reading
|
Thinking while reading
|
||||||
</div>
|
</div>
|
||||||
<textarea
|
<PathInput
|
||||||
value={value}
|
value={value}
|
||||||
onChange={(e) => onChange(e.target.value)}
|
onValueChange={onChange}
|
||||||
/* py-0.5 (owner round, 2026-10-07): the scrollbar fix. Source
|
/* 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
|
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
|
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."
|
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"
|
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>
|
</div>
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { findSessionTextMatches } from "@/src/core";
|
|||||||
* hidden/unmounted sections do not. Never walk embedded documents or drafts.
|
* hidden/unmounted sections do not. Never walk embedded documents or drafts.
|
||||||
* Do NOT exclude aria-hidden globally: KaTeX's VISIBLE HTML uses it too. */
|
* Do NOT exclude aria-hidden globally: KaTeX's VISIBLE HTML uses it too. */
|
||||||
const EXCLUDED = [
|
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]",
|
"title", "defs", "metadata", "desc", ".katex-mathml", ".sr-only", "[hidden]",
|
||||||
"[data-session-find-exclude]", "[data-lh-menu]", '[role="menu"]', '[role="dialog"]', '[role="tooltip"]',
|
"[data-session-find-exclude]", "[data-lh-menu]", '[role="menu"]', '[role="dialog"]', '[role="tooltip"]',
|
||||||
].join(",");
|
].join(",");
|
||||||
|
|||||||
@@ -50,6 +50,7 @@ const STORAGE_KEY = "lh:sidebar-open";
|
|||||||
*/
|
*/
|
||||||
import type { ChatGPTControlBridge, NativeSubscriptionControlBridge } from "@/src/core";
|
import type { ChatGPTControlBridge, NativeSubscriptionControlBridge } from "@/src/core";
|
||||||
import type { UpdateState } from "@/components/settings/update-settings.tsx";
|
import type { UpdateState } from "@/components/settings/update-settings.tsx";
|
||||||
|
import { FilePathDropZone } from "@/components/file-path-drop-zone";
|
||||||
const SIDEBAR_AUTOCLOSE = 928;
|
const SIDEBAR_AUTOCLOSE = 928;
|
||||||
|
|
||||||
// Module scope, guarded for SSR (the server has no navigator).
|
// Module scope, guarded for SSR (the server has no navigator).
|
||||||
@@ -61,6 +62,8 @@ declare global {
|
|||||||
lh?: {
|
lh?: {
|
||||||
/** Read-only host OS; determines the native-control gutter. */
|
/** Read-only host OS; determines the native-control gutter. */
|
||||||
platform?: string;
|
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. */
|
/** External Next desktop-dev uses the server's web connections. */
|
||||||
subscriptionTransport?: "desktop" | "web";
|
subscriptionTransport?: "desktop" | "web";
|
||||||
openSubscriptionBrowser?: (action: string) => Promise<void>;
|
openSubscriptionBrowser?: (action: string) => Promise<void>;
|
||||||
@@ -944,7 +947,7 @@ export function AppShell({ children }: { children: React.ReactNode }) {
|
|||||||
return (
|
return (
|
||||||
<SettingsDialogProvider>
|
<SettingsDialogProvider>
|
||||||
<SessionsSidebar />
|
<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) —
|
{/* The single document-level keydown listener (keyboard wave A) —
|
||||||
after the content in the tree, but it's window-level so order only
|
after the content in the tree, but it's window-level so order only
|
||||||
matters for the events it dispatches, never for DOM priority. */}
|
matters for the events it dispatches, never for DOM priority. */}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { useEffect, useRef, useState } from "react";
|
|||||||
import { ChevronDownIcon } from "lucide-react";
|
import { ChevronDownIcon } from "lucide-react";
|
||||||
import { blurAfterPointerClick } from "@/components/focus-hygiene";
|
import { blurAfterPointerClick } from "@/components/focus-hygiene";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
import { PathText } from "@/components/file-path-chip";
|
||||||
|
|
||||||
/** The unified user-thinking row (owner round, 2026-10-05): ONE visual
|
/** The unified user-thinking row (owner round, 2026-10-05): ONE visual
|
||||||
* language for every surface where the learner's own thinking renders —
|
* 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")} />
|
<ChevronDownIcon className={cn("size-4 shrink-0 transition-transform", open ? "rotate-180" : "rotate-0")} />
|
||||||
</button>
|
</button>
|
||||||
{open && (
|
{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>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -29,7 +29,7 @@
|
|||||||
*
|
*
|
||||||
* Channel names are mirrored in electron/main.cjs — keep them in sync.
|
* 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 openSettingsListener = null;
|
||||||
let pendingOpenSettings = null;
|
let pendingOpenSettings = null;
|
||||||
@@ -40,6 +40,9 @@ ipcRenderer.on("lh:open-settings", (_event, section) => {
|
|||||||
|
|
||||||
contextBridge.exposeInMainWorld("lh", {
|
contextBridge.exposeInMainWorld("lh", {
|
||||||
platform: process.platform,
|
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") ? {
|
...(process.argv?.includes("--lh-web-subscriptions") ? {
|
||||||
subscriptionTransport: "web",
|
subscriptionTransport: "web",
|
||||||
openSubscriptionBrowser: (action) => ipcRenderer.invoke("lh:subscriptions:web-browser", action),
|
openSubscriptionBrowser: (action) => ipcRenderer.invoke("lh:subscriptions:web-browser", action),
|
||||||
|
|||||||
@@ -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 });
|
||||||
|
}
|
||||||
@@ -109,14 +109,14 @@ try {
|
|||||||
// Release synthetic streams from prior cases rather than pin slots.
|
// Release synthetic streams from prior cases rather than pin slots.
|
||||||
await Promise.all(held.splice(0).map(request => request.abort("aborted").catch(() => {})));
|
await Promise.all(held.splice(0).map(request => request.abort("aborted").catch(() => {})));
|
||||||
await page.goto(origin + route, { waitUntil: "domcontentloaded", timeout: 120000 });
|
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"]'));
|
await page.waitForFunction(() => !document.querySelector('[data-testid="setup-cta"]') && !document.querySelector('[data-testid="setup-cta-keys"]'));
|
||||||
}
|
}
|
||||||
async function fill(text) {
|
async function fill(text) {
|
||||||
await page.evaluate(value => {
|
await page.evaluate(value => {
|
||||||
const textarea = document.querySelector('[data-testid="composer"] textarea') || document.querySelector("textarea");
|
const input = document.querySelector('[data-testid="composer"] [data-file-path-input]') || document.querySelector("[data-file-path-input]");
|
||||||
Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype, "value").set.call(textarea, value);
|
input.textContent = value;
|
||||||
textarea.dispatchEvent(new Event("input", { bubbles: true }));
|
input.dispatchEvent(new Event("input", { bubbles: true }));
|
||||||
}, text);
|
}, text);
|
||||||
}
|
}
|
||||||
async function submit() {
|
async function submit() {
|
||||||
@@ -127,7 +127,10 @@ try {
|
|||||||
await page.locator('button[aria-label="Start session"], [data-testid="composer"] button[aria-label="Send"]').click();
|
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 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();
|
const long = "A full synthetic syllabus — 世界 & ? #. ".repeat(1200).trim();
|
||||||
for (const route of ["/", "/chat/new"]) {
|
for (const route of ["/", "/chat/new"]) {
|
||||||
for (const text of ["a short synthetic topic", long]) {
|
for (const text of ["a short synthetic topic", long]) {
|
||||||
@@ -163,7 +166,7 @@ try {
|
|||||||
await fill(long); await submit();
|
await fill(long); await submit();
|
||||||
await page.waitForSelector('[role="alert"]');
|
await page.waitForSelector('[role="alert"]');
|
||||||
assert.equal(new URL(page.url()).pathname, route);
|
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(posts.length, sendCount);
|
||||||
assert.equal(creates.length, createCount + 1);
|
assert.equal(creates.length, createCount + 1);
|
||||||
await page.evaluate(() => { window.failOpeningStorage = false; });
|
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));
|
console.log(JSON.stringify({ checks, pageErrors: errors, isolation: "synthetic catalog/POST+streams; empty auth; no providers", origin }, null, 2));
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(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;
|
console.error(logs); process.exitCode = 1;
|
||||||
} finally {
|
} finally {
|
||||||
await browser?.close();
|
await browser?.close();
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -42,6 +42,8 @@ export * from "./math-delims.ts";
|
|||||||
export * from "./native-messages.ts";
|
export * from "./native-messages.ts";
|
||||||
export * from "./compaction.ts";
|
export * from "./compaction.ts";
|
||||||
export * from "./input-events.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
|
// Wave D (owner-approved 2026-09-26): the client view model — the grouping
|
||||||
// canon (one continuous assistant document, step separators) ported from
|
// canon (one continuous assistant document, step separators) ported from
|
||||||
// the deleted server projection to the client side. Pure, browser-safe.
|
// the deleted server projection to the client side. Pure, browser-safe.
|
||||||
|
|||||||
@@ -96,7 +96,7 @@ describe("desktop window chrome", () => {
|
|||||||
runInNewContext(readFileSync(new URL("../electron/preload.cjs", import.meta.url), "utf8"), context);
|
runInNewContext(readFileSync(new URL("../electron/preload.cjs", import.meta.url), "utf8"), context);
|
||||||
assert.equal(bridge.platform, "win32");
|
assert.equal(bridge.platform, "win32");
|
||||||
assert.deepEqual(Object.keys(bridge).sort(), [
|
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"]);
|
assert.deepEqual(Object.keys(bridge.subscriptions as object).sort(), ["cancel", "consent", "disconnect", "login", "onChange", "open", "respond", "retry", "status"]);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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/);
|
||||||
|
});
|
||||||
@@ -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"\)/);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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, /<reasoning>/);
|
||||||
|
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中文"]);
|
||||||
|
});
|
||||||
@@ -96,7 +96,7 @@ describe("question submission draft restoration", () => {
|
|||||||
|
|
||||||
it("restores an ask's custom answer and a free-text answer", () => {
|
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} />);
|
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"/);
|
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} />);
|
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"));
|
assert.ok(text.includes("preserved free-text answer"));
|
||||||
|
|||||||
@@ -100,7 +100,7 @@ describe("DraftShell — the sessionless surface", () => {
|
|||||||
const html = renderToStaticMarkup(<DraftShell busy={false} text="keep all my text" onChange={() => {}} onSend={() => {}} startError />);
|
const html = renderToStaticMarkup(<DraftShell busy={false} text="keep all my text" onChange={() => {}} onSend={() => {}} startError />);
|
||||||
assert.ok(html.includes('role="alert"'));
|
assert.ok(html.includes('role="alert"'));
|
||||||
assert.ok(html.includes(OPENING_PROMPT_FAILURE));
|
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", () => {
|
it("renders the composer blob, the stats bar, and the fresh-chat empty state", () => {
|
||||||
const html = renderToStaticMarkup(
|
const html = renderToStaticMarkup(
|
||||||
|
|||||||
@@ -81,13 +81,14 @@ describe("sessionFindRanges — the displayed-text DOM seam", () => {
|
|||||||
assert.ok((ranges[0]!.startContainer as Text).data.includes("session"));
|
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="">
|
mount(`<div data-session-find-root="">
|
||||||
<p>needle visible</p>
|
<p>needle visible</p>
|
||||||
<p hidden>needle hidden-attr</p>
|
<p hidden>needle hidden-attr</p>
|
||||||
<div data-lh-menu><p>needle menu</p></div>
|
<div data-lh-menu><p>needle menu</p></div>
|
||||||
<span class="katex-mathml">needle mathml</span>
|
<span class="katex-mathml">needle mathml</span>
|
||||||
<textarea>needle draft</textarea>
|
<textarea>needle draft</textarea>
|
||||||
|
<div contenteditable="true" data-file-path-input>needle path draft <span contenteditable="false">notes.pdf</span></div>
|
||||||
</div>`);
|
</div>`);
|
||||||
const ranges = sessionFindRanges(document.body as unknown as HTMLElement, "needle");
|
const ranges = sessionFindRanges(document.body as unknown as HTMLElement, "needle");
|
||||||
assert.equal(ranges.length, 1);
|
assert.equal(ranges.length, 1);
|
||||||
|
|||||||
Reference in New Issue
Block a user