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 { GlyphMark } from "@/components/ui/glyph-mark";
|
||||
import { ThinkingBlock } from "@/components/thinking-block";
|
||||
import { PathText } from "@/components/file-path-chip";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { ToolContentResponse } from "@/components/ai-elements/message";
|
||||
import type { QuestionAnswerData } from "@/src/core";
|
||||
@@ -153,13 +154,13 @@ export function AnsweredCard({ answer: savedAnswer, input }: { answer: QuestionA
|
||||
data-testid="answered-other"
|
||||
className="flex items-center gap-2 rounded-lg border border-primary bg-accent px-3 py-1.5"
|
||||
>
|
||||
<span>“{answer.other}”</span>
|
||||
<span>“<PathText text={answer.other} />”</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{answer.text && <div className="lh-prose mt-2 whitespace-pre-wrap">“{answer.text}”</div>}
|
||||
{answer.text && <div className="lh-prose mt-2 whitespace-pre-wrap">“<PathText text={answer.text} />”</div>}
|
||||
|
||||
{/* The confidence record (owner round 6): the slider's rail, disc
|
||||
removed — the missing handle itself says "this is a record, not a
|
||||
|
||||
+7
-6
@@ -14,6 +14,7 @@ import {
|
||||
ConversationScrollButton,
|
||||
} from "@/components/ai-elements/conversation";
|
||||
import { Message, MessageContent, MessageResponse } from "@/components/ai-elements/message";
|
||||
import { PathText } from "@/components/file-path-chip";
|
||||
import { Reasoning, ReasoningContent, ReasoningTrigger } from "@/components/ai-elements/reasoning";
|
||||
import { Shimmer } from "@/components/ai-elements/shimmer";
|
||||
import { BrailleSpinner } from "@/components/braille-spinner";
|
||||
@@ -687,10 +688,10 @@ export function Chat({
|
||||
const cycleChatFields = useCallback((): readonly HTMLElement[] => {
|
||||
const pick = (sel: string) => Array.from(document.querySelectorAll<HTMLElement>(sel));
|
||||
return [
|
||||
...pick('[data-testid="reading-thinking-field"] textarea'),
|
||||
...pick('[data-testid="question-dock"] textarea'),
|
||||
...pick('[data-testid="practice-dock"] textarea'),
|
||||
...pick('[data-testid="composer"] textarea'),
|
||||
...pick('[data-testid="reading-thinking-field"] [data-file-path-input]'),
|
||||
...pick('[data-testid="question-dock"] [data-file-path-input]'),
|
||||
...pick('[data-testid="practice-dock"] [data-file-path-input]'),
|
||||
...pick('[data-testid="composer"] [data-file-path-input]'),
|
||||
];
|
||||
}, []);
|
||||
useFieldCycle(cycleChatFields);
|
||||
@@ -705,7 +706,7 @@ export function Chat({
|
||||
* composer: with a question pending the Composer is unmounted, the ref
|
||||
* is null, and the thinking field is left alone by design. */
|
||||
const dictationWasActiveRef = useRef(false);
|
||||
const composerInputRef = useRef<HTMLTextAreaElement>(null);
|
||||
const composerInputRef = useRef<HTMLDivElement>(null);
|
||||
useEffect(() => {
|
||||
if (dictation.active) {
|
||||
dictationWasActiveRef.current = true;
|
||||
@@ -1507,7 +1508,7 @@ export function Chat({
|
||||
rail as the thinking rows — one quote geometry. */}
|
||||
<div className="font-medium uppercase tracking-wide text-muted-foreground">You</div>
|
||||
<div className="lh-prose whitespace-pre-wrap text-foreground">
|
||||
{m.parts.filter((p) => p.type === "text").map((p) => p.text).join("\n")}
|
||||
<PathText text={m.parts.filter((p) => p.type === "text").map((p) => p.text).join("\n")} />
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { ArrowRightIcon, CornerDownLeftIcon, SquareIcon } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Hint } from "@/components/ui/hint";
|
||||
import { PathInput } from "@/components/path-input";
|
||||
|
||||
/**
|
||||
* The chat composer — content row ONLY (the persistent rounded shell lives in
|
||||
@@ -59,7 +60,7 @@ export function Composer({
|
||||
autoFocus?: boolean;
|
||||
/** The page's handle to the textarea — the dictation-end focus (owner,
|
||||
* 2026-10-01): a run ending hands the cursor to the bare composer. */
|
||||
inputRef?: React.RefObject<HTMLTextAreaElement | null>;
|
||||
inputRef?: React.RefObject<HTMLDivElement | null>;
|
||||
/** Escape while a dictation runs stops it (STT wave, 2026-09-17). No-op
|
||||
* when absent — Esc otherwise means nothing in the composer. */
|
||||
onEscape?: () => void;
|
||||
@@ -90,10 +91,11 @@ export function Composer({
|
||||
act();
|
||||
}}
|
||||
>
|
||||
<textarea
|
||||
<PathInput
|
||||
ref={inputRef}
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
onValueChange={onChange}
|
||||
defaultDropTarget
|
||||
onKeyDown={(e) => {
|
||||
// Esc ends dictation before anything else Esc might someday mean
|
||||
// here (the dictate decisions: click-again or Esc to stop).
|
||||
@@ -115,7 +117,6 @@ export function Composer({
|
||||
}}
|
||||
placeholder={placeholder}
|
||||
autoFocus={autoFocus}
|
||||
rows={1}
|
||||
className="field-sizing-content lh-input max-h-48 min-h-10 flex-1 resize-none bg-transparent px-2.5 py-2 outline-none placeholder:text-muted-foreground"
|
||||
/>
|
||||
{/* The button follows the text (owner report 2026-10-03): while
|
||||
|
||||
@@ -199,6 +199,17 @@ export function rmsToMeter(rms: number): number {
|
||||
* window, one pending question, one target at a time.
|
||||
*/
|
||||
let questionTarget: ((text: string) => void) | null = null;
|
||||
let composerTarget: ((text: string) => void) | null = null;
|
||||
|
||||
/** File references use the very same default destination as dictation,
|
||||
* including claims whose dock is currently collapsed. A focused editor can
|
||||
* insert at its caret instead; callers use this only as the fallback. */
|
||||
export function appendToPrimaryInput(text: string): boolean {
|
||||
const target = questionTarget ?? composerTarget;
|
||||
if (!target) return false;
|
||||
target(text);
|
||||
return true;
|
||||
}
|
||||
|
||||
/* The finisher registry: submit paths FAR from the hook's owner (the
|
||||
* pending question's dock) need finish() without a prop bridge. Same
|
||||
@@ -280,6 +291,13 @@ export function useDictation({ append }: { append: (text: string) => void }): Di
|
||||
* retarget (composer↔question) is honored on the very next final. */
|
||||
const appendRef = useRef(append);
|
||||
appendRef.current = append;
|
||||
useEffect(() => {
|
||||
const target = (text: string) => appendRef.current(text);
|
||||
composerTarget = target;
|
||||
return () => {
|
||||
if (composerTarget === target) composerTarget = null;
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Live machinery (non-render values). The MediaStream tracks stop and
|
||||
// the per-run nodes disconnect on teardown; the socket is recreated per
|
||||
|
||||
@@ -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 { StatsBar } from "@/components/stats-bar";
|
||||
import { OPENING_PROMPT_FAILURE } from "@/components/opening-prompt";
|
||||
import { PathInput } from "@/components/path-input";
|
||||
import { SessionStarter } from "@/components/start-session";
|
||||
import type { TurnUsage } from "@/src/core";
|
||||
|
||||
@@ -64,7 +65,7 @@ export function Home({
|
||||
/* Run end focuses the topic input (owner, 2026-10-01): the hands are on
|
||||
* the keyboard from the stop. Falling edge only; this surface's only
|
||||
* dictation target IS this textarea, so no case-split is needed. */
|
||||
const topicInputRef = useRef<HTMLTextAreaElement>(null);
|
||||
const topicInputRef = useRef<HTMLDivElement>(null);
|
||||
// Tab navigates INTO the topic composer (owner, keyboard round
|
||||
// 2026-10-06): the only text field on this surface — and home's
|
||||
// composer is its OWN textarea, not the shared Composer component,
|
||||
@@ -248,13 +249,14 @@ export function Home({
|
||||
void start();
|
||||
}}
|
||||
>
|
||||
<textarea
|
||||
<PathInput
|
||||
ref={topicInputRef}
|
||||
value={topic}
|
||||
onChange={(e) => {
|
||||
topicRef.current = e.target.value;
|
||||
setTopic(e.target.value);
|
||||
onValueChange={(value) => {
|
||||
topicRef.current = value;
|
||||
setTopic(value);
|
||||
}}
|
||||
defaultDropTarget
|
||||
onKeyDown={(e) => {
|
||||
// Esc ends dictation (the dictate decisions: click-again or
|
||||
// Esc) — stopPropagation claims it (keyboard wave A) so
|
||||
@@ -272,7 +274,6 @@ export function Home({
|
||||
}}
|
||||
placeholder={mode === "practice" ? "What do you want to practice?" : "What do you want to learn?"}
|
||||
autoFocus
|
||||
rows={1}
|
||||
className="field-sizing-content lh-input max-h-48 min-h-10 flex-1 resize-none bg-transparent px-2.5 py-2 outline-none placeholder:text-muted-foreground"
|
||||
/>
|
||||
{/* No tooltip on the start square — the ↵ glyph is the
|
||||
|
||||
@@ -114,7 +114,7 @@ export function DraftShell({
|
||||
// Tab navigates INTO the composer (owner, keyboard round 2026-10-06):
|
||||
// the only text field on this surface — the cycle's whole chain.
|
||||
const cycleComposer = useCallback((): readonly HTMLElement[] =>
|
||||
Array.from(document.querySelectorAll<HTMLElement>('[data-testid="composer"] textarea')),
|
||||
Array.from(document.querySelectorAll<HTMLElement>('[data-testid="composer"] [data-file-path-input]')),
|
||||
[]);
|
||||
useFieldCycle(cycleComposer);
|
||||
const dictation = useDictation({
|
||||
@@ -138,7 +138,7 @@ export function DraftShell({
|
||||
};
|
||||
/* Run end focuses the composer (owner, 2026-10-01): the hands are on
|
||||
* the keyboard from the stop — falling edge of `active` only. */
|
||||
const composerInputRef = useRef<HTMLTextAreaElement>(null);
|
||||
const composerInputRef = useRef<HTMLDivElement>(null);
|
||||
const dictationWasActiveRef = useRef(false);
|
||||
useEffect(() => {
|
||||
if (dictation.active) {
|
||||
|
||||
@@ -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 { joinDictation, useDictationTarget } from "@/components/dictation";
|
||||
import { useEnterSubmits } from "@/components/enter-submits";
|
||||
import { PathInput } from "@/components/path-input";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
/**
|
||||
@@ -121,11 +122,11 @@ export function PracticeDock({ problemCallId, input, expanded, onExpand, onSubmi
|
||||
<div className="px-2.5 pt-2 text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
||||
YOUR THINKING
|
||||
</div>
|
||||
<textarea
|
||||
<PathInput
|
||||
value={thinking}
|
||||
onChange={(e) => {
|
||||
thinkingRef.current = e.target.value;
|
||||
setThinking(e.target.value);
|
||||
onValueChange={(value) => {
|
||||
thinkingRef.current = value;
|
||||
setThinking(value);
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
// Esc ends dictation — stopPropagation claims it (the
|
||||
@@ -144,7 +145,6 @@ export function PracticeDock({ problemCallId, input, expanded, onExpand, onSubmi
|
||||
}
|
||||
}}
|
||||
placeholder="Work here, think out loud."
|
||||
rows={4}
|
||||
className={cn(
|
||||
"field-sizing-content lh-input max-h-64 min-h-16 w-full resize-none bg-transparent px-2.5 pb-2 outline-none placeholder:text-muted-foreground",
|
||||
)}
|
||||
|
||||
@@ -5,6 +5,7 @@ import { ArrowDownIcon, ChevronRightIcon, PlayIcon } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { finishDictation, isDictationActive, joinDictation, useDictationTarget } from "@/components/dictation";
|
||||
import { useEnterSubmits } from "@/components/enter-submits";
|
||||
import { PathInput } from "@/components/path-input";
|
||||
import { ToolContentResponse } from "@/components/ai-elements/message";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
@@ -439,8 +440,8 @@ function QuestionDockForm({
|
||||
expanded — collapsed, the bar's native Enter expands it. */
|
||||
const [kbdIdx, setKbdIdx] = useState<number | null>(null);
|
||||
const rootRef = useRef<HTMLDivElement>(null);
|
||||
const otherInputRef = useRef<HTMLInputElement>(null);
|
||||
const thinkingRef = useRef<HTMLTextAreaElement>(null);
|
||||
const otherInputRef = useRef<HTMLDivElement>(null);
|
||||
const thinkingRef = useRef<HTMLDivElement>(null);
|
||||
// One row past the options is the trailing row: quiz → don't-know,
|
||||
// ask → Other (a question never has both). Text mode (ask/explain)
|
||||
// has no rows — the composer IS the answer surface.
|
||||
@@ -473,7 +474,7 @@ function QuestionDockForm({
|
||||
const focusRow = (idx: number) => {
|
||||
const row = rootRef.current?.querySelector(`[data-kbdrow="${idx}"]`) as HTMLElement | null;
|
||||
if (!row) return;
|
||||
const el = (row.querySelector("input") as HTMLElement | null) ?? row;
|
||||
const el = (row.querySelector("input, [data-file-path-input]") as HTMLElement | null) ?? row;
|
||||
el.focus();
|
||||
};
|
||||
|
||||
@@ -696,11 +697,12 @@ function QuestionDockForm({
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<input
|
||||
<PathInput
|
||||
ref={otherInputRef}
|
||||
singleLine
|
||||
value={other}
|
||||
onChange={(e) => {
|
||||
setOther(e.target.value);
|
||||
onValueChange={(value) => {
|
||||
setOther(value);
|
||||
// Typing selects the row — displacing in
|
||||
// single-select, alongside the siblings in multi.
|
||||
setSel((prev) => (prev.otherSelected ? prev : toggleOther(selectMode, prev)));
|
||||
@@ -851,11 +853,11 @@ function QuestionDockForm({
|
||||
{qd.mode === "text" ? "Your answer" : "YOUR THINKING"}
|
||||
</div>
|
||||
{qd.mode === "text" ? (
|
||||
<textarea
|
||||
<PathInput
|
||||
value={text}
|
||||
onChange={(e) => {
|
||||
answerTextRef.current = e.target.value;
|
||||
setText(e.target.value);
|
||||
onValueChange={(value) => {
|
||||
answerTextRef.current = value;
|
||||
setText(value);
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
// Esc ends dictation (the dictate decisions: click-again
|
||||
@@ -875,16 +877,15 @@ function QuestionDockForm({
|
||||
}
|
||||
}}
|
||||
placeholder="Type your answer…"
|
||||
rows={1}
|
||||
className="field-sizing-content lh-input max-h-48 min-h-10 w-full resize-none bg-transparent px-2.5 py-2 outline-none placeholder:text-muted-foreground"
|
||||
/>
|
||||
) : (
|
||||
<textarea
|
||||
<PathInput
|
||||
ref={thinkingRef}
|
||||
value={thinking}
|
||||
onChange={(e) => {
|
||||
thinkingTextRef.current = e.target.value;
|
||||
setThinking(e.target.value);
|
||||
onValueChange={(value) => {
|
||||
thinkingTextRef.current = value;
|
||||
setThinking(value);
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
// Esc ends dictation (the dictate decisions: click-again
|
||||
@@ -905,7 +906,6 @@ function QuestionDockForm({
|
||||
}
|
||||
}}
|
||||
placeholder="Why this answer? Whatever comes to mind."
|
||||
rows={1}
|
||||
className={cn(
|
||||
"field-sizing-content lh-input max-h-48 w-full resize-none bg-transparent px-2.5 py-2 outline-none placeholder:text-muted-foreground/70",
|
||||
// Phase-scaled prominence: teaching invites depth,
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { useCallback } from "react";
|
||||
import { joinDictation, useDictationTarget } from "@/components/dictation";
|
||||
import { PathInput } from "@/components/path-input";
|
||||
|
||||
/**
|
||||
* The reading-thinking field (owner design 2026-10-03, "two questions per
|
||||
@@ -54,9 +55,9 @@ export function ReadingThinkingField({
|
||||
<div className="pb-1 text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
||||
Thinking while reading
|
||||
</div>
|
||||
<textarea
|
||||
<PathInput
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
onValueChange={onChange}
|
||||
/* py-0.5 (owner round, 2026-10-07): the scrollbar fix. Source
|
||||
Sans 3's font box is 22px at 16px; lh-input's 1.25 leading
|
||||
gives a 20px line box — the content area sticks 1px below the
|
||||
@@ -95,7 +96,6 @@ export function ReadingThinkingField({
|
||||
}
|
||||
}}
|
||||
placeholder="Whatever comes to mind as you read."
|
||||
rows={1}
|
||||
className="field-sizing-content lh-input max-h-40 w-full resize-none bg-transparent py-0.5 outline-none placeholder:text-muted-foreground/70"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -4,7 +4,7 @@ import { findSessionTextMatches } from "@/src/core";
|
||||
* hidden/unmounted sections do not. Never walk embedded documents or drafts.
|
||||
* Do NOT exclude aria-hidden globally: KaTeX's VISIBLE HTML uses it too. */
|
||||
const EXCLUDED = [
|
||||
"script", "style", "noscript", "textarea", "input", "select", "iframe", "canvas",
|
||||
"script", "style", "noscript", "textarea", "input", "select", "iframe", "canvas", '[contenteditable="true"]',
|
||||
"title", "defs", "metadata", "desc", ".katex-mathml", ".sr-only", "[hidden]",
|
||||
"[data-session-find-exclude]", "[data-lh-menu]", '[role="menu"]', '[role="dialog"]', '[role="tooltip"]',
|
||||
].join(",");
|
||||
|
||||
@@ -50,6 +50,7 @@ const STORAGE_KEY = "lh:sidebar-open";
|
||||
*/
|
||||
import type { ChatGPTControlBridge, NativeSubscriptionControlBridge } from "@/src/core";
|
||||
import type { UpdateState } from "@/components/settings/update-settings.tsx";
|
||||
import { FilePathDropZone } from "@/components/file-path-drop-zone";
|
||||
const SIDEBAR_AUTOCLOSE = 928;
|
||||
|
||||
// Module scope, guarded for SSR (the server has no navigator).
|
||||
@@ -61,6 +62,8 @@ declare global {
|
||||
lh?: {
|
||||
/** Read-only host OS; determines the native-control gutter. */
|
||||
platform?: string;
|
||||
/** Resolve an OS-backed File without copying/uploading its contents. */
|
||||
getPathForFile?: (file: File) => string;
|
||||
/** External Next desktop-dev uses the server's web connections. */
|
||||
subscriptionTransport?: "desktop" | "web";
|
||||
openSubscriptionBrowser?: (action: string) => Promise<void>;
|
||||
@@ -944,7 +947,7 @@ export function AppShell({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<SettingsDialogProvider>
|
||||
<SessionsSidebar />
|
||||
<main className="min-w-0 flex-1">{children}</main>
|
||||
<FilePathDropZone className="relative min-w-0 flex-1">{children}</FilePathDropZone>
|
||||
{/* The single document-level keydown listener (keyboard wave A) —
|
||||
after the content in the tree, but it's window-level so order only
|
||||
matters for the events it dispatches, never for DOM priority. */}
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useEffect, useRef, useState } from "react";
|
||||
import { ChevronDownIcon } from "lucide-react";
|
||||
import { blurAfterPointerClick } from "@/components/focus-hygiene";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { PathText } from "@/components/file-path-chip";
|
||||
|
||||
/** The unified user-thinking row (owner round, 2026-10-05): ONE visual
|
||||
* language for every surface where the learner's own thinking renders —
|
||||
@@ -127,7 +128,7 @@ export function ThinkingBlock({
|
||||
<ChevronDownIcon className={cn("size-4 shrink-0 transition-transform", open ? "rotate-180" : "rotate-0")} />
|
||||
</button>
|
||||
{open && (
|
||||
<span className="lh-prose mt-1.5 block whitespace-pre-wrap text-[15px] italic text-foreground/50">{text}</span>
|
||||
<span className="lh-prose mt-1.5 block whitespace-pre-wrap text-[15px] italic text-foreground/50"><PathText text={text} /></span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user