diff --git a/docs/external-plugin-guide.md b/docs/external-plugin-guide.md index 29ef27d..3636563 100644 --- a/docs/external-plugin-guide.md +++ b/docs/external-plugin-guide.md @@ -256,7 +256,10 @@ interface TabComponentProps { // 以下由内置 tab 使用,外部 tab 可忽略: expanded?: string[] // 文件树的展开目录集 onToggleDir?: (path: string) => void - onReferenceFile?: (path: string) => void + // 在会话 composer 插入一条 @ 引用。isDir=true 为目录:纯文本 `@dir/`, + // 保留宿主文件夹装饰与补全;false 为文件:走宿主结构化引用 chip + // (显示 @basename、序列化为完整 @path),宿主拒绝时回退纯文本。 + onReferenceFile?: (path: string, isDir: boolean) => void onOpenFile?: (path: string) => void onOpenDiff?: (tab: SidebarTab) => void onSubagentJump?: (childSessionId: string) => void 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 `