From 94ffac0224980957a5d5cf0fd16b35987334d806 Mon Sep 17 00:00:00 2001 From: Ch3n4y Date: Wed, 26 Aug 2026 22:29:01 +0800 Subject: [PATCH 1/2] fix(editor): keep file basename in @-reference links MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The explorer @ button appended plain text (@folder/file.ts), which DSH's composer only decorates as a folder ref (@folder/) — the file name stayed outside the link. Insert FILE references through DSH's own structured insert-reference event instead, minting one chip for the whole reference (displayed as @basename, serialized as the full @path). Directories keep the plain @dir/ mention so folder completion still works. --- src/client/EditorHost.tsx | 2 +- src/client/FileTree.tsx | 8 ++-- src/client/Sidebar.tsx | 31 +++++++++----- src/client/TreePanel.tsx | 2 +- src/client/conversation-draft.ts | 70 +++++++++++++++++++++++++++++++- src/client/service.ts | 2 +- src/context-types.ts | 5 ++- tests/reference-mention.spec.ts | 30 ++++++++++++++ 8 files changed, 129 insertions(+), 21 deletions(-) create mode 100644 tests/reference-mention.spec.ts diff --git a/src/client/EditorHost.tsx b/src/client/EditorHost.tsx index 9db60cf..5a98960 100644 --- a/src/client/EditorHost.tsx +++ b/src/client/EditorHost.tsx @@ -100,7 +100,7 @@ export function EditorHost(props: { expanded: string[] revealed: string[] onToggleDir: (path: string) => void - onReferenceFile: (path: string) => void + onReferenceFile: (path: string, isDir: boolean) => void }) { const { ctx, store, scope, tab, expanded, revealed, onToggleDir, onReferenceFile } = props const path = tab.path ?? '' diff --git a/src/client/FileTree.tsx b/src/client/FileTree.tsx index 684ceb4..9f0a7dc 100644 --- a/src/client/FileTree.tsx +++ b/src/client/FileTree.tsx @@ -131,8 +131,8 @@ export function FileTree(props: { onOpenWith?: (targetId: string, path: string) => void /** Toggle one target's pinned state (the submenu row's pushpin). */ onToggleOpenWithPin?: (targetId: string) => void - /** Insert `@` into the composer draft. */ - onReferenceFile: (path: string) => void + /** Insert `@` into the composer draft (file vs directory). */ + onReferenceFile: (path: string, isDir: boolean) => void /** Bump to wipe the level cache and reload the visible set. */ refreshTick: number /** Upload into `dir` (absolute, inside the workspace); runs in the caller. */ @@ -332,7 +332,7 @@ export function FileTree(props: { title={t('referenceFile')} onClick={(event) => { event.stopPropagation() - onReferenceFile(entry.path) + onReferenceFile(entry.path, entry.isDir) }} > {t('referenceFile')} @@ -570,7 +570,7 @@ export function FileTree(props: { title={t('referenceFile')} onClick={(event) => { event.stopPropagation() - onReferenceFile(root) + onReferenceFile(root, true) }} > {t('referenceFile')} diff --git a/src/client/Sidebar.tsx b/src/client/Sidebar.tsx index fa5f205..264591e 100644 --- a/src/client/Sidebar.tsx +++ b/src/client/Sidebar.tsx @@ -33,7 +33,7 @@ import { useSyncExternalStore } from 'react' import clsx from 'clsx' import { IconCloseFill14, Tooltip } from '@deepseek-ai/dsh-client-ui-primitives' import type { Context, SidebarSessionList } from '../context-types.ts' -import { appendToDraft } from './conversation-draft.ts' +import { appendToDraft, insertFileReference } from './conversation-draft.ts' import { BOTTOM_MIN, PANEL_MIN, activateTab, agentUuidOf, allLeaves, closeFloatByTab, closeTab, dockFloat, firstLeaf, floatTab, floatWithTab, isAgentTabId, leafWithTab, migrateBottomTabs, @@ -128,7 +128,7 @@ function injectUserCss(attr: string, id: string, cssText: string): HTMLStyleElem * pane; sessionId/cwd cover onReferenceFile). */ interface TabContentProps extends TabContentMemoKey { onToggleDir: (path: string) => void - onReferenceFile: (path: string) => void + onReferenceFile: (path: string, isDir: boolean) => void ctx: Context store: SidebarStore /** Fired before a topology node jumps to its child session (see Sidebar). */ @@ -1376,17 +1376,26 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) { }, [actions, pinnedVirtualTabs, activePinnedTabId, store]) /** - * The explorer's @-reference button: append `@` to the - * session's composer draft (space-separated). The conversation service is - * resolved lazily through `ctx.get` (the inject-free read — the app's own - * plugins read 'conversation' the same way); a missing service or scope - * degrades to a logged no-op, never a crash. Defined above the no-session - * early return — a hook must never sit behind a conditional return - * (React counts hooks per render). + * The explorer's @-reference button. Directories append the folder mention + * (`@dir/`) as plain text so DSH's folder decoration and completion keep + * working; files insert a structured chip like the native `@` picker, so + * the whole reference stays one link instead of decorating only the + * leading folder. Resolves the session-scope ctx and the conversation + * input service at click time; a missing service or scope degrades to a + * logged no-op, never a crash. Defined above the no-session early return + * — a hook must never sit behind a conditional return (React counts hooks + * per render). */ - const referenceInChat = useCallback((path: string): void => { + const referenceInChat = useCallback((path: string, isDir: boolean): void => { if (sessionId === undefined) return - appendToDraft(ctx, sessionId, `@${relativeTo(cwd ?? '', path)}`) + const rel = relativeTo(cwd ?? '', path) + if (isDir) { + appendToDraft(ctx, sessionId, `@${rel === '.' ? './' : `${rel}/`}`) + return + } + if (!insertFileReference(ctx, sessionId, rel)) { + appendToDraft(ctx, sessionId, `@${rel}`) + } }, [ctx, sessionId, cwd]) if (state === undefined || sessionId === undefined) { diff --git a/src/client/TreePanel.tsx b/src/client/TreePanel.tsx index e6638cf..942291f 100644 --- a/src/client/TreePanel.tsx +++ b/src/client/TreePanel.tsx @@ -62,7 +62,7 @@ export function TreePanel(props: { openWithSsh?: boolean onOpenWith?: (targetId: string, path: string) => void onToggleOpenWithPin?: (targetId: string) => void - onReferenceFile: (path: string) => void + onReferenceFile: (path: string, isDir: boolean) => void /** Full-window presentation: the panel fills its host instead of docking * at a fixed width. */ full?: boolean diff --git a/src/client/conversation-draft.ts b/src/client/conversation-draft.ts index a565c38..3432523 100644 --- a/src/client/conversation-draft.ts +++ b/src/client/conversation-draft.ts @@ -5,6 +5,14 @@ * through `ctx.get` (the inject-free read the app's own plugins use); a * missing service or scope degrades to a logged no-op, never a crash. * + * File references additionally use DSH's own structured insert event + * (`slash/input-insert-reference`, see `insertFileReference`) instead of + * plain draft text: the native `@file` picker emits this event, and the + * conversation input machine mints one occurrence whose chip covers the + * whole reference. Plain text `@folder/file.ts` only ever gets DSH's + * folder-ref decoration (`@folder/`) — the file name stays undecorated — so + * plain append is the fallback, not the primary path, for files. + * * Insert position (fixes upstream issue #425): the draft store only exposes * the whole string (`getSnapshot().draft` + `setDraft(text)`) — there is no * caret API on the conversation service. The composer's `