From b18d8ef3aafa6bacea5f4ac3a2380b98d59a6d36 Mon Sep 17 00:00:00 2001 From: yhh <359807859@qq.com> Date: Wed, 19 Aug 2026 23:52:04 -0700 Subject: [PATCH] feat(studio): storage relocation, composer intake, and the checkpoint agreement MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Accumulated work from the branch, carried across the rebase onto origin/studio and merged with what landed there in the meantime. Storage: a workspace can name where its state lives, and the server tracks the one relocation it may be running (internal/storage, config/storage_roots, serve/storage, the Storage panel). Composer intake: what a drop or a long paste becomes is one pure function — payload in, action out — so "what happens if I drop a folder" is a test rather than a branch of an onDrop handler. It sits on the drop router that landed upstream: that one answers which pane a drop hit, this one answers what it means and says so before the pointer is released. A paste long enough to bury the composer becomes a chip beside it instead. Session: the checkpoint agreement and the lineage/torn-log recovery cases, plus the conflict diagnostic learning where two transcripts parted and which roles stood there — a position and a role tell a local reshape from a foreign write without recording any content. Model refs: the default-model write checks the live catalog, so an extension model the runtime knows can be persisted as the default. --- AGENTS.md | 6 + desktop/frontend-next/src/i18n/en.ts | 1 + desktop/frontend-next/src/i18n/en_settings.ts | 4 +- desktop/frontend-next/src/port/mock.ts | 46 +-- desktop/frontend-next/src/port/mock_mcp.ts | 33 ++ .../frontend-next/src/port/mock_provider.ts | 11 +- .../frontend-next/src/port/mock_storage.ts | 28 ++ desktop/frontend-next/src/port/port.ts | 24 +- desktop/frontend-next/src/port/provider.ts | 32 ++ desktop/frontend-next/src/port/sse.ts | 21 +- desktop/frontend-next/src/port/storage.ts | 72 ++++ desktop/frontend-next/src/styles/app.css | 154 +++++++- desktop/frontend-next/src/ui/Composer.tsx | 323 +++++++++++---- desktop/frontend-next/src/ui/Onboarding.tsx | 4 +- desktop/frontend-next/src/ui/Providers.tsx | 29 +- desktop/frontend-next/src/ui/Settings.tsx | 9 +- desktop/frontend-next/src/ui/Storage.tsx | 309 +++++++++++++++ desktop/frontend-next/src/ui/filedrop.ts | 50 ++- desktop/frontend-next/src/ui/intake.test.ts | 132 +++++++ desktop/frontend-next/src/ui/intake.ts | 171 ++++++++ desktop/frontend-next/src/ui/useIntake.ts | 75 ++++ desktop/frontend-next/vite.config.ts | 2 +- desktop/next/main.go | 4 +- internal/agent/save.go | 83 +--- internal/agent/save_conflict.go | 95 +++++ internal/agent/save_external_write_test.go | 62 +++ .../agent/session_checkpoint_agreement.go | 56 +++ .../agent/session_checkpoint_lineage_test.go | 86 ++++ internal/agent/session_events.go | 2 +- internal/agent/session_lease.go | 39 ++ internal/agent/session_lease_test.go | 19 + .../agent/session_torn_log_recovery_test.go | 185 +++++++++ internal/cli/model.go | 8 +- internal/cli/model_test.go | 38 +- internal/cli/setup_manager.go | 4 +- internal/config/config.go | 21 +- internal/config/edit.go | 55 +-- internal/config/edit_test.go | 67 ++-- internal/config/load.go | 10 +- internal/config/paths.go | 90 +---- internal/config/render.go | 19 + internal/config/skill_name.go | 25 ++ internal/config/storage_config.go | 141 +++++++ internal/config/storage_roots.go | 245 ++++++++++++ internal/config/storage_roots_test.go | 271 +++++++++++++ .../control/snapshot_conflict_diagnostic.go | 18 +- internal/serve/attachments.go | 35 +- internal/serve/model_refs_test.go | 131 +++++++ internal/serve/modelcaps_test.go | 4 +- internal/serve/roles.go | 2 +- internal/serve/serve.go | 13 +- internal/serve/serve_test.go | 21 + internal/serve/settings.go | 8 +- internal/serve/storage.go | 219 +++++++++++ internal/serve/storage_test.go | 167 ++++++++ internal/storage/move.go | 371 ++++++++++++++++++ internal/storage/move_test.go | 235 +++++++++++ internal/storage/preflight.go | 189 +++++++++ internal/storage/survey.go | 166 ++++++++ internal/storage/survey_test.go | 133 +++++++ internal/storage/volume_unix.go | 19 + internal/storage/volume_windows.go | 30 ++ 62 files changed, 4512 insertions(+), 410 deletions(-) create mode 100644 AGENTS.md create mode 100644 desktop/frontend-next/src/port/mock_mcp.ts create mode 100644 desktop/frontend-next/src/port/mock_storage.ts create mode 100644 desktop/frontend-next/src/port/storage.ts create mode 100644 desktop/frontend-next/src/ui/Storage.tsx create mode 100644 desktop/frontend-next/src/ui/intake.test.ts create mode 100644 desktop/frontend-next/src/ui/intake.ts create mode 100644 desktop/frontend-next/src/ui/useIntake.ts create mode 100644 internal/agent/save_conflict.go create mode 100644 internal/agent/session_checkpoint_agreement.go create mode 100644 internal/agent/session_checkpoint_lineage_test.go create mode 100644 internal/agent/session_torn_log_recovery_test.go create mode 100644 internal/config/skill_name.go create mode 100644 internal/config/storage_config.go create mode 100644 internal/config/storage_roots.go create mode 100644 internal/config/storage_roots_test.go create mode 100644 internal/serve/model_refs_test.go create mode 100644 internal/serve/storage.go create mode 100644 internal/serve/storage_test.go create mode 100644 internal/storage/move.go create mode 100644 internal/storage/move_test.go create mode 100644 internal/storage/preflight.go create mode 100644 internal/storage/survey.go create mode 100644 internal/storage/survey_test.go create mode 100644 internal/storage/volume_unix.go create mode 100644 internal/storage/volume_windows.go diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000000..f16da08602 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,6 @@ +# Reasonix + +The project's standing instructions live in `REASONIX.md` — one file, read by +both Reasonix and Codex. Add rules there, never here. + +@REASONIX.md diff --git a/desktop/frontend-next/src/i18n/en.ts b/desktop/frontend-next/src/i18n/en.ts index 94ea5682e7..6e370f3c90 100644 --- a/desktop/frontend-next/src/i18n/en.ts +++ b/desktop/frontend-next/src/i18n/en.ts @@ -174,6 +174,7 @@ export const EN: Record = { "松手 → 插入到光标处": "Release to insert at the caret", "工作区外的文件引用不到 · 这里放不下": "A file outside the workspace cannot be referenced — this drop has nowhere to go", "图片": "image", + "文件": "file", "粘贴的文本": "pasted text", "{n} 行 · 展开到输入框": "{n} lines · put it back in the box", "移除这张图":"Remove this image", diff --git a/desktop/frontend-next/src/i18n/en_settings.ts b/desktop/frontend-next/src/i18n/en_settings.ts index 54b00c9ad0..e0ada39e6e 100644 --- a/desktop/frontend-next/src/i18n/en_settings.ts +++ b/desktop/frontend-next/src/i18n/en_settings.ts @@ -165,8 +165,8 @@ export const EN_SETTINGS: Record = { "卡住了,要你介入": "Stuck — needs you", "端点要求的额外设置": "Extra settings this endpoint demands", "上下文窗口(tokens)": "Context window (tokens)", - "留空 = 用内置的已知值;0 = 这个来源不自动压缩": - "Empty = use the built-in known value; 0 = never auto-compact on this source", + "留空 = 用内置的已知值;自己加的来源没有内置值,那就是不压缩": + "Empty = use the built-in known value; a source you added yourself has none, which means no compaction", "填模型文档写的上下文上限,不是最大输出。填小了会一直压缩,填大了会在真到上限时被端点拒绝。": "Use the context limit from the model's documentation, not its max output. Too small and it compacts constantly; too large and the endpoint refuses once the real limit is hit.", "思考参数": "Thinking parameter", diff --git a/desktop/frontend-next/src/port/mock.ts b/desktop/frontend-next/src/port/mock.ts index ba162a75c7..1ff2e869d7 100644 --- a/desktop/frontend-next/src/port/mock.ts +++ b/desktop/frontend-next/src/port/mock.ts @@ -1,7 +1,9 @@ -import type { AccountState, AgentPort, Completion, CompletionItem, DeviceGrant, ProviderCheck, VersionHub, ApprovalMode, ApprovalVerdict, Checkpoint, RewindPlan, RewindResult, RewindScope, HistoryMessage, ModelEntry, Preset, ProviderSetup, RoleAssignments, SessionEntry, SessionStatus, CapabilityScope, McpCatalog, McpDraft, McpDraftServer, McpEntry, McpInstallResult, ScopeLayer, HookCatalog, HookDryRun, HookEntry, MemoryCatalog, MemoryEntry, NetworkProbe, NetworkSettings, McpRisk, WorkspaceInfo, WorkspaceChanges, Attachment, DroppedRef, ThemePack } from "./port"; +import type { AccountState, AgentPort, Completion, CompletionItem, DeviceGrant, ProviderCheck, VersionHub, ApprovalMode, ApprovalVerdict, Checkpoint, RewindPlan, RewindResult, RewindScope, HistoryMessage, ModelEntry, Preset, ProviderSetup, RoleAssignments, SessionEntry, SessionStatus, CapabilityScope, McpCatalog, McpDraft, McpDraftServer, McpEntry, McpInstallResult, ScopeLayer, HookCatalog, HookDryRun, HookEntry, MemoryCatalog, MemoryEntry, NetworkProbe, NetworkSettings, WorkspaceInfo, WorkspaceChanges, Attachment, DroppedRef, ThemePack } from "./port"; import type { WireEvent } from "./wire"; import { MockProvider } from "./mock_provider"; import { SCRIPT } from "./fixture"; +import { mockStorage, mockStoragePlan } from "./mock_storage"; +import { risksOf, toEntry } from "./mock_mcp"; export class MockPort extends MockProvider implements AgentPort { @@ -756,6 +758,18 @@ export class MockPort extends MockProvider implements AgentPort { async setPreset(preset: Preset) { this.state.preset = preset; } + async storage() { + return mockStorage(); + } + + async planStorageMove(root: string, dir: string) { + return mockStoragePlan(root, dir); + } + + async moveStorage(root: string, dir: string) { + return mockStoragePlan(root, dir); + } + async setModel(ref: string) { this.state.modelRef = ref; this.state.label = ref.split("/").pop() ?? ref; @@ -768,33 +782,3 @@ export class MockPort extends MockProvider implements AgentPort { } } -function toEntry(s: McpDraftServer): McpEntry { - return { - name: s.name, - state: "idle", - enabled: true, - transport: s.transport, - source: "user config", - tools: 0, - }; -} - -const SECRETY = /auth|token|secret|credential|api[-_]?key|cookie/i; - -function risksOf(servers: McpDraftServer[]): McpRisk[] { - const out: McpRisk[] = []; - for (const s of servers) { - if (s.command) { - out.push({ server: s.name, kind: "shell", field: "command", detail: [s.command, ...(s.args ?? [])].join(" ") }); - } - if (s.url) out.push({ server: s.name, kind: "unknown-host", field: "url", detail: s.url }); - for (const [k, v] of Object.entries(s.env ?? {})) { - if (SECRETY.test(k) && !v.startsWith("$")) { - out.push({ server: s.name, kind: "secret", field: "env." + k, - detail: `明文写进配置文件;改成 \${${k.toUpperCase()}} 可以只留在环境变量里` }); - } - } - } - return out; -} - diff --git a/desktop/frontend-next/src/port/mock_mcp.ts b/desktop/frontend-next/src/port/mock_mcp.ts new file mode 100644 index 0000000000..5f9f804b7e --- /dev/null +++ b/desktop/frontend-next/src/port/mock_mcp.ts @@ -0,0 +1,33 @@ +// Fixtures for the add-a-server flow: what a draft looks like once accepted, +// and what the kernel would flag about it before it is. +import type { McpDraftServer, McpEntry, McpRisk } from "./port"; + +export function toEntry(s: McpDraftServer): McpEntry { + return { + name: s.name, + state: "idle", + enabled: true, + transport: s.transport, + source: "user config", + tools: 0, + }; +} + +const SECRETY = /auth|token|secret|credential|api[-_]?key|cookie/i; + +export function risksOf(servers: McpDraftServer[]): McpRisk[] { + const out: McpRisk[] = []; + for (const s of servers) { + if (s.command) { + out.push({ server: s.name, kind: "shell", field: "command", detail: [s.command, ...(s.args ?? [])].join(" ") }); + } + if (s.url) out.push({ server: s.name, kind: "unknown-host", field: "url", detail: s.url }); + for (const [k, v] of Object.entries(s.env ?? {})) { + if (SECRETY.test(k) && !v.startsWith("$")) { + out.push({ server: s.name, kind: "secret", field: "env." + k, + detail: `明文写进配置文件;改成 \${${k.toUpperCase()}} 可以只留在环境变量里` }); + } + } + } + return out; +} diff --git a/desktop/frontend-next/src/port/mock_provider.ts b/desktop/frontend-next/src/port/mock_provider.ts index 92fde94504..a130e6e31c 100644 --- a/desktop/frontend-next/src/port/mock_provider.ts +++ b/desktop/frontend-next/src/port/mock_provider.ts @@ -1,4 +1,4 @@ -import type { ProviderEdit, ProviderEntry, ProviderProbe } from "./port"; +import type { Protocol, ProviderEdit, ProviderEntry, ProviderProbe } from "./port"; import { MockBoundary } from "./mock_boundary"; // The sources half of the fixture. Chained on for the reason the others are: @@ -38,6 +38,15 @@ export class MockProvider extends MockBoundary { return this.sources; } + // Mirrors the kernel catalog: two wires answer one OpenAI model listing. + async protocols(): Promise { + return [ + { kind: "openai", discovery: "openai", serverWebSearch: false, reasoningParams: true }, + { kind: "responses", discovery: "openai", serverWebSearch: true, reasoningParams: false }, + { kind: "anthropic", discovery: "anthropic", serverWebSearch: true, reasoningParams: false }, + ]; + } + async probeProvider(): Promise { throw new Error("演示模式不会真的去连端点"); } diff --git a/desktop/frontend-next/src/port/mock_storage.ts b/desktop/frontend-next/src/port/mock_storage.ts new file mode 100644 index 0000000000..42e7b9e58c --- /dev/null +++ b/desktop/frontend-next/src/port/mock_storage.ts @@ -0,0 +1,28 @@ +import type { StoragePlan, StorageState } from "./storage"; + +// A C: drive with three gigabytes left and a session store that ate the rest — +// the situation the panel exists for, so the fixture shows it rather than a +// tidy empty install. +const VOL = { volume: "C:", volumeFree: 3_100_000_000, volumeTotal: 476_000_000_000 }; +const ROAMING = "C:/Users/you/AppData/Roaming/reasonix"; +const LOCAL = "C:/Users/you/AppData/Local/reasonix"; + +export function mockStorage(): StorageState { + return { + editable: true, + roots: [ + { id: "state", dir: ROAMING, bytes: 6_800_000_000, files: 9_612, relocatable: true, ...VOL }, + { id: "cache", dir: LOCAL, bytes: 1_900_000_000, files: 42, relocatable: true, ...VOL }, + { id: "worktrees", dir: `${LOCAL}/worktrees`, bytes: 412_000_000, files: 1_204, relocatable: true, ...VOL }, + { id: "home", dir: ROAMING, bytes: 12_288, files: 3, relocatable: false, ...VOL }, + { id: "locks", dir: LOCAL, bytes: 0, files: 0, relocatable: false, missing: true }, + ], + }; +} + +export function mockStoragePlan(root: string, dir: string): StoragePlan { + return { + root, from: ROAMING, to: dir, bytes: 6_800_000_000, files: 9_612, + need: 7_068_435_456, free: 214_000_000_000, ok: true, + }; +} diff --git a/desktop/frontend-next/src/port/port.ts b/desktop/frontend-next/src/port/port.ts index 549f20a1ce..ac0e15d1bc 100644 --- a/desktop/frontend-next/src/port/port.ts +++ b/desktop/frontend-next/src/port/port.ts @@ -37,7 +37,8 @@ import type { WireEvent } from "./wire"; import type { PluginExport, PluginInstallRequest, PluginPackage, PluginPlan } from "./plugin"; import type { Appearance, ThemePack } from "./look"; import type { PermissionLists, PermissionRules, SandboxSettings } from "./boundary"; -import type { ProviderCheck, ProviderDraft, ProviderEdit, ProviderEntry, ProviderProbe } from "./provider"; +import type { Protocol, ProviderCheck, ProviderDraft, ProviderEdit, ProviderEntry, ProviderProbe, ProviderSetup } from "./provider"; +import type { StoragePlan, StorageState } from "./storage"; export type * from "./plugin"; @@ -53,12 +54,12 @@ export interface ContextBreakdown { output: number; } -// GET /history returns the provider conversation, not the event stream: the -// stream is live-only, so a reload rebuilds the transcript from these. +// GET /history: the event stream is live-only, so a reload rebuilds from these. export interface HistoryMessage { role: "system" | "user" | "assistant" | "tool"; content: string; reasoning?: string; + images?: number; // attachments on a user turn; an image-only one has no text toolCalls?: { id: string; name: string; arguments?: string }[]; toolCallId?: string; toolName?: string; @@ -152,17 +153,6 @@ export interface JobEntry { // The UI depends on this and nothing else. SsePort talks to internal/serve; // MockPort replays a fixture. Neither is allowed to leak transport details // upward, which is what keeps the same UI usable in a browser and in Wails. -// GET /provider-setup 404s once a usable key exists, so null means "ready". -export interface ProviderSetup { - required: boolean; - provider?: string; - model?: string; - modelRef?: string; - keyEnv?: string; - error?: string; - activationPending?: boolean; -} - export interface SessionEntry { name: string; path: string; @@ -548,7 +538,13 @@ export interface AgentPort { // Persisted, then the runtime is rebuilt: boot reads every role model while // assembling, so an assignment cannot reach a runtime that is already up. setRole(role: string, ref: string): Promise; + storage(): Promise; + planStorageMove(root: string, dir: string): Promise; + moveStorage(root: string, dir: string): Promise; providers(): Promise; + // The wire formats a source may be saved as. Read from the kernel so a + // protocol added there reaches this panel without a frontend release. + protocols(): Promise; // Asks an endpoint what it is. Writes nothing — the answer is shown for // confirmation, because only the person holding the key knows what they // bought. diff --git a/desktop/frontend-next/src/port/provider.ts b/desktop/frontend-next/src/port/provider.ts index ce3c34a17d..f501e42aaf 100644 --- a/desktop/frontend-next/src/port/provider.ts +++ b/desktop/frontend-next/src/port/provider.ts @@ -40,11 +40,28 @@ export interface ProviderEntry { extraBody?: Record; } +// One wire format a source may be saved as, as the kernel declares it. The +// list is the kernel's and the words for it are ours: no label rides along. +export interface Protocol { + kind: string; + // Which model-listing shape this wire is discovered under. Protocols sharing + // one value answer the same listing and cannot be told apart by a probe. + discovery: string; + // Whether the wire has a format for a provider-executed web search tool, and + // whether thinking/effort fields ride it at all. + serverWebSearch: boolean; + reasoningParams: boolean; +} + // What an endpoint turned out to be. Every field is a guess the user confirms // before anything is written — a model list cannot prove which protocol a // gateway speaks, only which ones it answers. export interface ProviderProbe { kind: string; + // Every kind that listing may be driven with, kernel order. `kind` is the + // pre-selection among them, not the only answer: DeepSeek serves both the + // OpenAI chat wire and the Responses API off one model list. + kinds: string[]; authHeader: boolean; models: string[]; default: string; @@ -64,6 +81,9 @@ export interface ProviderProbe { export interface ProviderCheck { ok: boolean; kind?: string; + // Whether that answer is consistent with the kind the entry records. A + // Responses source answers the OpenAI listing, so equality is the wrong test. + matches?: boolean; models?: string[]; ambiguous?: boolean; noProxy?: boolean; @@ -103,3 +123,15 @@ export interface ProviderDraft { effort: string; vision: string[]; } + +// What the opening sequence still owes a machine with no usable key. GET +// /provider-setup 404s once one exists, so null means "ready". +export interface ProviderSetup { + required: boolean; + provider?: string; + model?: string; + modelRef?: string; + keyEnv?: string; + error?: string; + activationPending?: boolean; +} diff --git a/desktop/frontend-next/src/port/sse.ts b/desktop/frontend-next/src/port/sse.ts index ebc5673226..4188028169 100644 --- a/desktop/frontend-next/src/port/sse.ts +++ b/desktop/frontend-next/src/port/sse.ts @@ -1,7 +1,8 @@ import { download } from "./download"; -import type { AccountState, AgentPort, Appearance, ContextBreakdown, Completion, DeviceGrant, ProviderCheck, ProviderDraft, ProviderEdit, ProviderEntry, ProviderProbe, UpdateProgress, VersionHub, ApprovalMode, ApprovalVerdict, Checkpoint, RewindPlan, RewindResult, RewindScope, HistoryMessage, ModelEntry, Preset, ProviderSetup, RoleAssignments, SessionEntry, SessionStatus, HookCatalog, HookDryRun, HookEntry, MemoryCatalog, NetworkProbe, NetworkSettings, ShellSettings, PermissionLists, PermissionRules, SandboxSettings, McpCatalog, McpDraft, McpDraftServer, McpInstallResult, McpInstallScope, CapabilityScope, ScopeLayer, PluginExport, PluginInstallRequest, PluginPackage, PluginPlan, SkillCatalog, WorkspaceInfo, ThemePack } from "./port"; +import type { AccountState, AgentPort, Appearance, ContextBreakdown, Completion, DeviceGrant, Protocol, ProviderCheck, ProviderDraft, ProviderEdit, ProviderEntry, ProviderProbe, UpdateProgress, VersionHub, ApprovalMode, ApprovalVerdict, Checkpoint, RewindPlan, RewindResult, RewindScope, HistoryMessage, ModelEntry, Preset, ProviderSetup, RoleAssignments, SessionEntry, SessionStatus, HookCatalog, HookDryRun, HookEntry, MemoryCatalog, NetworkProbe, NetworkSettings, ShellSettings, PermissionLists, PermissionRules, SandboxSettings, McpCatalog, McpDraft, McpDraftServer, McpInstallResult, McpInstallScope, CapabilityScope, ScopeLayer, PluginExport, PluginInstallRequest, PluginPackage, PluginPlan, SkillCatalog, WorkspaceInfo, ThemePack } from "./port"; import { HttpError, type Attachment, type DroppedRef, type WorkspaceChanges } from "./port"; import { SseHttp } from "./sse_http"; +import type { StoragePlan, StorageState } from "./storage"; import type { WireEvent } from "./wire"; // The running project is the default, so its requests stay the bare path they @@ -317,6 +318,10 @@ export class SsePort extends SseHttp implements AgentPort { return this.get("/providers"); } + protocols() { + return this.get("/providers/protocols"); + } + // The failure message is the answer here — a 401, a wrong path and "no chat // models" send the user to three different fixes — so it is read out of the // body rather than thrown away as a status code. @@ -365,6 +370,20 @@ export class SsePort extends SseHttp implements AgentPort { return this.post("/roles", { role, ref }); } + storage() { + return this.get("/storage"); + } + + // Both answer with a plan: a refused move is reported in the body rather + // than as a failed request, because its refusals are what the panel shows. + planStorageMove(root: string, dir: string) { + return this.post0("/storage/plan", { root, dir }); + } + + moveStorage(root: string, dir: string) { + return this.post0("/storage/move", { root, dir }); + } + // Like the add-a-source probe, the interesting answer is in the body: a // refused key and a moved endpoint are different fixes. async checkProvider(name: string): Promise { diff --git a/desktop/frontend-next/src/port/storage.ts b/desktop/frontend-next/src/port/storage.ts new file mode 100644 index 0000000000..fe687c0658 --- /dev/null +++ b/desktop/frontend-next/src/port/storage.ts @@ -0,0 +1,72 @@ +// What the kernel keeps on disk, and moving it somewhere else. Sizes are the +// ones it measured — a person deletes things on the strength of these numbers, +// so nothing here is an estimate. + +// One declared root. relocatable and pinnedBy come from the kernel's own root +// table: the panel never decides for itself which rows may be moved. +export interface StorageRoot { + id: string; + dir: string; + bytes: number; + files: number; + relocatable: boolean; + // The environment variable holding this root in place, when one does. A + // pinned root shows where it is and why it cannot be moved from here. + pinnedBy?: string; + // Never written yet. Not a failure — a fresh install has no worktrees. + missing?: boolean; + err?: string; + volume?: string; + volumeFree?: number; + volumeTotal?: number; +} + +// A move in flight, or the one that just ended. Polled rather than pushed: it +// is a single operation someone is watching, on a panel that already refreshes. +export interface StorageMove { + root: string; + to: string; + phase: string; + bytes: number; + total: number; + detail?: string; + err?: string; + done: boolean; +} + +// An unfinished move the kernel found recorded at launch. +export interface StoragePending { + root: string; + to: string; + phase: string; +} + +export interface StorageState { + roots: StorageRoot[]; + // False when the host has not opened up configuration writing; the panel then + // reports sizes and offers nothing it cannot carry out. + editable: boolean; + move?: StorageMove; + pending?: StoragePending; +} + +// One objection to a move. code is what a client branches on, detail the +// sentence a person reads; both arrive together so neither has to be inferred. +export interface StorageRefusal { + code: string; + detail: string; +} + +// What a move would do, answered before any of it is done. need is the bytes +// required including the headroom the kernel insists on leaving. +export interface StoragePlan { + root: string; + from: string; + to: string; + bytes: number; + files: number; + need: number; + free: number; + ok: boolean; + refusals?: StorageRefusal[]; +} diff --git a/desktop/frontend-next/src/styles/app.css b/desktop/frontend-next/src/styles/app.css index f202ee628f..a65e951834 100644 --- a/desktop/frontend-next/src/styles/app.css +++ b/desktop/frontend-next/src/styles/app.css @@ -1033,6 +1033,40 @@ @keyframes blink { 0%, 100% { opacity: 1 } 55% { opacity: .15 } } .tk .caret { width: 5px; height: 11px; background: var(--faint); margin-left: 2px } + /* 收工回执:它说的是「证据」,不是「进度」。所以整张卡不渲染 verdict 名词 + —— 主机知道自己验到了什么、没验到什么,但完全不知道这一单还剩多少, + 把「没验证」写成「没做完」就是拿一句它答不上来的话去否定用户刚看完的 + 那一屏。三档权重:干净的一轮沉成一行 ghost;这一轮自己申报的用 hair + 起小节、不给色相(是主动说的,不是被查出来的);只有主机找出来的缺口 + 才配那道 2px warn。 */ + .rcpt { padding: 7px 0 3px 31px } + /* 只淡入不位移:位移读作「又来了一步」,而这是这一轮落停。 */ + .rcpt[data-lvl="ok"] { + display: flex; align-items: baseline; gap: 8px; flex-wrap: wrap; + font: 400 11.5px var(--ui); color: var(--faint); + animation: settle .26s var(--ease-out) both; + } + .rcpt[data-lvl="ok"] .tick { font-size: 10px; color: var(--ok); opacity: .75 } + .rcpt[data-lvl="ok"] .src { font: 400 10.5px var(--mono); color: var(--ghost); min-width: 0; overflow-wrap: anywhere } + @keyframes settle { from { opacity: 0 } to { opacity: 1 } } + + .rcpt-open { display: flex; flex-direction: column; gap: 10px; padding-top: 9px } + /* 干净那行落在展开卡里时不再吃一次左边距 */ + .rcpt-open .rcpt { padding: 0 } + .rgroup { display: flex; flex-direction: column; gap: 4px } + .rgroup .hd { font: 500 10px var(--mono); letter-spacing: .07em; color: var(--warn) } + .rgroup[data-declared] .hd { color: var(--faint) } + /* 逐行错开 40ms:五行一起弹出来是一坨,错开才读得出这是一份清单 */ + .rrow { + padding: 5px 0 5px 10px; border-left: 2px solid var(--warn); + animation: card var(--t-card) var(--ease-out) both; + animation-delay: calc(var(--i, 0) * 40ms); + } + .rgroup[data-declared] .rrow { border-left-color: var(--hair) } + .rrow .t { display: block; font: 400 12px var(--ui); color: var(--text) } + .rrow .d { display: block; margin-top: 2px; font: 400 11px var(--mono); color: var(--muted); overflow-wrap: anywhere } + .rgroup .more { padding-left: 12px; font: 400 11px var(--ui); color: var(--ghost) } + /* review findings:严重度用左侧 2px 色条编码,不新增文字标签 */ .finds { margin-top: 7px; display: flex; flex-direction: column; gap: 5px } .find { padding: 6px 0 6px 10px; border-left: 2px solid var(--ghost); animation: card var(--t-card) var(--ease-out) both } @@ -1041,6 +1075,13 @@ .find[data-lvl="err"] { border-left-color: var(--err) } .find .t { display: block; font: 500 12.5px var(--ui); color: var(--text) } .find .why { display: block; margin-top: 2px; font-size: 11.5px; line-height: 1.55; color: var(--muted) } + /* 通知的第二行是机器文本:系统原话、命令、来源路径。等宽 + 保留换行,长 + 路径断得开 —— 拿正文字体铺成一坨,就会和标题糊在一起,也就是用户看到 + 的那面墙。 */ + .find .why.nwhy { + margin-top: 5px; font: 400 11px/1.6 var(--mono); color: var(--faint); + white-space: pre-wrap; overflow-wrap: anywhere; + } .term { margin-top: 6px; padding: 8px 11px; background: var(--overlay); border-radius: var(--r-sm); font: 400 11.5px/19px var(--mono); color: var(--muted); overflow-x: auto } .term-l { white-space: pre } @@ -1314,15 +1355,79 @@ /* 减去 .flow 的内边距,输入框的可见边缘才跟卡片的左右边缘落在同一条线上 */ /* 附件是「这一轮要带上的东西」,所以它贴在输入框上沿,不在别处 */ - .compose textarea[data-over] { outline: 2px dashed var(--accent); outline-offset: -3px } /* 输入区整块都收拖放,但它本身不该占布局:display:contents 让盒子消失、 节点还在,拖放层就能问「这一下落在输入框里没有」。 */ .dropzone { display: contents } - .shots { display: flex; flex-wrap: wrap; align-items: center; gap: 6px; padding: 0 2px 7px } - .shot { display: inline-flex; align-items: center; gap: 6px; padding: 3px 4px 3px 9px; border: 1px solid var(--border); border-radius: var(--r-pill); font: 400 11px var(--mono); color: var(--muted) } - .shot .nm { max-width: 22ch; overflow: hidden; text-overflow: ellipsis; white-space: nowrap } - .shot button { border: 0; background: none; color: var(--ghost); cursor: pointer; font: 400 12px/1 var(--mono); padding: 0 3px } - .shot button:hover { color: var(--err) } + /* 落点是整块面板,不是输入框那 40px —— 瞄一行高的框,就是在瞄 40px。 + 外壳把整窗的落点都报上来,落在哪一格由 DOM 说了算(见 filedrop.ts)。 */ + .pane::after { + content: ""; position: absolute; inset: 0; z-index: 3; pointer-events: none; + background: color-mix(in srgb, var(--page) 74%, transparent); + opacity: 0; transition: opacity .12s var(--ease-out); + } + .pane[data-intake]::after { opacity: 1 } + /* 输入框自己抬起来换色,亮的是它而不是一个虚线矩形 */ + .pane[data-intake] .compose { + position: relative; z-index: 4; + transform: translateY(-2px); + transition: transform .12s var(--ease-out), box-shadow .12s var(--ease-out); + } + .pane[data-intake="attach"] .compose { box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 22%, transparent) } + .pane[data-intake="attach"] .compose::after { border-color: var(--accent) } + .pane[data-intake="ref"] .compose { box-shadow: 0 0 0 3px color-mix(in srgb, var(--net) 22%, transparent) } + .pane[data-intake="ref"] .compose::after { border-color: var(--net) } + .pane[data-intake="insert"] .compose { box-shadow: 0 0 0 3px color-mix(in srgb, var(--ok) 20%, transparent) } + .pane[data-intake="insert"] .compose::after { border-color: var(--ok) } + /* 拒绝态不抖也不闪红:落点单纯不亮,那行字说明原因 */ + .pane[data-intake="reject"] .compose { transform: none; box-shadow: none } + + /* 说的是动词,不是「把文件拖到这里」—— 松手之前就知道会得到什么 */ + .intake { + margin: 0 0 7px; padding: 0 2px; font: 500 11.5px var(--ui); + animation: intakein .12s var(--ease-out) both; + } + .intake[data-tone="attach"] { color: var(--accent) } + .intake[data-tone="ref"] { color: var(--net) } + .intake[data-tone="insert"] { color: var(--ok) } + .intake[data-tone="reject"] { color: var(--faint) } + @keyframes intakein { from { opacity: 0; transform: translateY(3px) } to { opacity: 1; transform: none } } + + /* 附件是「这一轮要带上的东西」,所以它贴在输入框上沿,不在别处。 + 连着贴两张截图,只有文件名不一样 —— 而那正是卡片唯一要说清的事, + 所以缩略图不是装饰。 */ + .shots { list-style: none; margin: 0; display: flex; flex-wrap: wrap; align-items: center; gap: 7px; padding: 0 2px 8px } + .shot { + display: inline-flex; align-items: center; gap: 8px; padding: 4px 4px 4px 5px; + border: 1px solid var(--border); border-radius: 7px; background: var(--raised); + animation: shotin .1s var(--ease-out) both; animation-delay: calc(var(--i, 0) * 45ms); + } + @keyframes shotin { from { opacity: 0; transform: scale(.94) } to { opacity: 1; transform: none } } + .shot .thumb { + width: 30px; height: 30px; flex: none; border-radius: 4px; background: var(--overlay); + background-size: cover; background-position: center; + } + .shot .glyph { + width: 30px; height: 30px; flex: none; border-radius: 4px; display: grid; place-items: center; + background: color-mix(in srgb, var(--net) 12%, transparent); color: var(--net); + font: 500 9.5px var(--mono); letter-spacing: .04em; + } + .shot .meta { display: flex; flex-direction: column; gap: 1px; min-width: 0; padding-right: 2px } + .shot .nm { max-width: 24ch; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font: 400 11.5px var(--mono); color: var(--text) } + .shot .sz { font: 400 10px var(--mono); color: var(--ghost) } + /* 收起来的粘贴要能原样放回去 —— 自动改掉用户手打的内容,代价比帮的忙大 */ + .shot .undo { + align-self: flex-start; border: 0; background: none; padding: 0; cursor: pointer; + font: 400 10px var(--mono); color: var(--faint); text-decoration: underline; + text-decoration-style: dotted; text-underline-offset: 2px; + } + .shot .undo:hover { color: var(--accent) } + .shot .x { + width: 22px; height: 22px; flex: none; border: 0; border-radius: var(--r-sm); cursor: pointer; + background: transparent; color: var(--ghost); font: 400 13px/1 var(--mono); + display: grid; place-items: center; transition: color .12s, background .12s; + } + .shot .x:hover { color: var(--err); background: color-mix(in srgb, var(--err) 12%, transparent) } + .shot button:focus-visible, .intake:focus-visible { outline: 2px solid var(--focus); outline-offset: 1px } .shots .warn { font: 400 11px var(--ui); color: var(--accent) } .compose { @@ -1578,7 +1683,9 @@ .call, .nest, .ask, .hits > *, .steps > *, .term-l, .dl, .plan .s, .find, .ag, .file, .jump, .menu, .nest-ret, .hl .cost, .sym, .pip[data-settled], .big .v[data-flash], .plan .s[data-done] .b, - .rmark, .rmark path, .await i, .apv, .guard, .medit, .medit-r, .endbar { animation: none !important } + .rmark, .rmark path, .await i, .apv, .guard, .medit, .medit-r, .endbar, + .rcpt[data-lvl="ok"], .rrow, .shot, .intake { animation: none !important } + .pane[data-intake] .compose { transform: none !important } .rmark path { stroke-dashoffset: 0 !important } /* 三个状态本来就靠颜色和那行字说话,停掉动画不丢任何信息 */ .reload, .reload .rdot, .rnote { animation: none !important } @@ -3577,3 +3684,36 @@ .ctxpop .row .v { font: 400 10.5px var(--mono); color: var(--text); font-variant-numeric: tabular-nums } .ctxpop .row .p { width: 30px; text-align: right; font: 400 10px var(--mono); color: var(--ghost) } .ctxpop .foot { margin: 3px 0 0; font: 400 10px/1.5 var(--ui); color: var(--ghost) } + + /* 存储:一屏三段 —— 占了多少、放在哪、正在搬什么。条形按最大的那一项归一, + 所以读的是相对关系而不是绝对像素;数字全部等宽对齐,因为它们是拿来比的。 */ + .storage .vol { display: flex; flex-direction: column; gap: 5px; margin-bottom: 12px } + .storage .vol .row { display: flex; align-items: baseline; justify-content: space-between; gap: 12px } + .storage .vol .nm { font: 500 12px var(--ui); color: var(--text) } + .storage .vol .sz { font: 400 10.5px var(--mono); color: var(--muted); font-variant-numeric: tabular-nums } + .storage .meter { height: 5px; border-radius: var(--r-xs); background: var(--hair); overflow: hidden } + .storage .meter > i { display: block; height: 100%; background: var(--accent); transition: width var(--t-line) var(--ease-out) } + .storage .drives { margin: 10px 0 0 } + .storage .drives p { margin: 0; font: 400 10.5px var(--mono); color: var(--muted); font-variant-numeric: tabular-nums } + + .storage .item { display: flex; align-items: flex-start; justify-content: space-between; gap: 14px; flex-wrap: wrap; + padding: 10px 12px; border: 1px solid var(--border); border-radius: var(--r-sm); margin-bottom: 8px } + .storage .item .l { display: flex; flex-direction: column; gap: 2px; min-width: 0; flex: 1 } + .storage .item .l b { font: 600 12px var(--ui); color: var(--text) } + .storage .item .hint { font: 400 11px var(--ui); color: var(--muted) } + .storage .item .p { font: 400 10.5px var(--mono); color: var(--ghost); word-break: break-all } + /* 被环境变量钉住、或本来就不可移动 —— 说明为什么这一行没有按钮 */ + .storage .item .held { font: 400 10.5px var(--mono); color: var(--warn) } + + .storage .mover { display: flex; flex-direction: column; gap: 8px; width: 100%; margin-top: 4px } + .storage .mover input { width: 100%; padding: 6px 9px; border: 1px solid var(--border); border-radius: var(--r-xs); + background: var(--surface); color: var(--text); font: 400 11px var(--mono) } + .storage .mover input:focus-visible { outline: 2px solid var(--focus); outline-offset: 1px } + .storage .mover .acts { display: flex; gap: 8px; justify-content: flex-end } + .storage .refusals { margin: 0; padding-left: 16px; display: flex; flex-direction: column; gap: 3px } + .storage .refusals li { font: 400 11px/1.5 var(--ui); color: var(--err) } + .storage .ready { margin: 0; font: 400 11px/1.5 var(--ui); color: var(--muted) } + .storage .moving .failed { margin: 0; font: 400 11px/1.5 var(--ui); color: var(--err) } + .storage .moving .row { display: flex; align-items: baseline; justify-content: space-between; gap: 12px; margin-bottom: 5px } + .storage .moving .nm { font: 500 12px var(--ui); color: var(--text) } + .storage .moving .sz { font: 400 10.5px var(--mono); color: var(--muted); font-variant-numeric: tabular-nums } diff --git a/desktop/frontend-next/src/ui/Composer.tsx b/desktop/frontend-next/src/ui/Composer.tsx index 06a5dbbccc..778386a792 100644 --- a/desktop/frontend-next/src/ui/Composer.tsx +++ b/desktop/frontend-next/src/ui/Composer.tsx @@ -1,11 +1,13 @@ -import { useEffect, useLayoutEffect, useRef, useState } from "react"; +import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react"; import { t } from "../i18n"; -import { useFileDrop, type Dropped } from "./filedrop"; import type { AgentPort, ApprovalMode, ModelEntry, SessionStatus, Attachment } from "../port/port"; import { Picker } from "./Menu"; import { modelMenu } from "./modelmenu"; import { CompletionMenu, useCompletion } from "./Completion"; import { useIme } from "./ime"; +import { countLines, pasteIsLong, planTone, planVerb } from "./intake"; +import { useIntake } from "./useIntake"; +import type { Dropped } from "./filedrop"; // 从紧到松排,和闸门环的缺口一个方向。「不打扰」在内核是 permission.Deny: // 它比「询问」更严,不是更松 —— 排在询问前面才不会被读反。 @@ -25,8 +27,13 @@ const APPROVALS: [ApprovalMode, string, string][] = [ const EFFORT_FALLBACK = ["auto", "low", "medium", "high", "xhigh", "max"]; function effortsFor(models: ModelEntry[], ref?: string): string[] { - const declared = models.find((m) => m.ref === ref)?.efforts; - return declared?.length ? declared : EFFORT_FALLBACK; + const model = models.find((m) => m.ref === ref); + // A model that is in the list and names no levels has none: the host omits + // the field exactly when it would refuse every level but auto. Filling that + // in from the fallback is what put a full ladder in front of a relay whose + // every rung came back refused. The fallback is for a list not answered yet. + if (model) return model.efforts ?? []; + return EFFORT_FALLBACK; } // 强度是有序的,批准是有序的 —— 一排全等的胶囊把这件事藏了起来。两个刻度把 @@ -43,13 +50,46 @@ interface Props { onError: (e: unknown) => void; } +// What is riding along with this turn. An image travels as bytes the kernel +// already saved; a long paste is the text itself, held out of the box so eight +// hundred lines of log do not become the composer. +type Chip = + | { k: "image"; id: string; a: Attachment; url?: string } + | { k: "paste"; id: string; body: string; lines: number; name?: string }; + +// Two screenshots pasted in a row are one filename apart, which is the one +// thing the chip has to tell them by. The preview comes off the blob that was +// just attached — the kernel keeps the bytes, this keeps a handle to look at. +function previewURL(blob: Blob): string | undefined { + try { + return URL.createObjectURL(blob); + } catch { + return undefined; + } +} + +// A dropped file has no preview to stand behind: the host named it, it was +// never read. Its kind fills the square, because a blank one reads as an image +// that failed to load. +function kindOf(path: string): string { + const name = path.split(/[\\/]/).pop() ?? path; + const dot = name.lastIndexOf("."); + return dot > 0 ? name.slice(dot + 1).toUpperCase().slice(0, 4) : "FILE"; +} + +function releaseChip(c: Chip) { + if (c.k === "image" && c.url) URL.revokeObjectURL(c.url); +} + +let chipSeq = 0; +const chipId = () => `c${++chipSeq}`; + export function Composer({ port, status, running, onSubmit, onChanged, onError }: Props) { const [text, setText] = useState(""); // The caret decides which token is being completed, so it is state here // rather than something read off the element when a menu happens to open. const [caret, setCaret] = useState(0); - const [shots, setShots] = useState([]); - const [over, setOver] = useState(false); + const [shots, setShots] = useState([]); const picker = useRef(null); const [models, setModels] = useState([]); const [switching, setSwitching] = useState(false); @@ -62,9 +102,13 @@ export function Composer({ port, status, running, onSubmit, onChanged, onError } port.models().then(setModels).catch(() => setModels([])); }, [port]); + // A drop lands where the caret is, and the handler that receives it was built + // once — so the position it reads has to be a ref, not the render's copy. + const caretRef = useRef(0); const type = (next: string, at: number) => { setText(next); setCaret(at); + caretRef.current = at; }; const menu = useCompletion(port, text, caret, (next, at) => { @@ -93,50 +137,114 @@ export function Composer({ port, status, running, onSubmit, onChanged, onError } }, [text]); // Attachments ride into the turn as path references, exactly as they do from - // the CLI. What the reference points at is the difference: a dropped file is - // still where it was, and only pasted bytes were written into the workspace. + // the CLI — the host saved the bytes, the turn parser resolves the token. A + // held-back paste follows the typed text, which is where it reads as the + // material the message is about rather than as part of the sentence. const send = () => { const v = text.trim(); if (!v && shots.length === 0) return; - const line = [...shots.map((a) => a.ref), v].filter(Boolean).join(" "); + const refs = shots.flatMap((c) => (c.k === "image" ? [c.a.ref] : [])); + const pastes = shots.flatMap((c) => (c.k === "paste" ? [c.body] : [])); + const line = [[...refs, v].filter(Boolean).join(" "), ...pastes].filter(Boolean).join("\n\n"); type("", 0); + shots.forEach(releaseChip); setShots([]); onSubmit(line); }; - // Bytes, for the two cases that have nothing else to offer: the clipboard, - // which never says where what it holds came from, and the picker, which hands - // back a File and no path. The host writes them into the workspace and the - // turn references that copy — which is right for a screenshot and wrong for a - // file that already exists somewhere, so a drop does not come through here. - const grab = (files: File[]) => { - if (files.length === 0) return; - Promise.all(files.map((f) => port.attach(f, f.name))) - .then((added) => setShots((prev) => [...prev, ...added])) - .catch(onError); - }; + const insert = useCallback((snippet: string) => { + const el = box.current; + const at = el ? el.selectionStart : caretRef.current; + setText((prev) => { + const cut = Math.min(at, prev.length); + const next = prev.slice(0, cut) + snippet + prev.slice(cut); + pending.current = cut + snippet.length; + return next; + }); + }, []); - // A dropped file is referenced where it lives. Copying it into the workspace - // is what let a turn edit the copy and report the edit as done while the file - // the user pointed at never changed — and what filled the attachment - // directory with a second version of everything anybody dragged in. - const dropped = (d: Dropped) => { - if (d.paths.length === 0) { - // A tab, or a drag that carried a promise rather than a file on disk. - grab(d.files.filter((f) => f.size > 0)); - return; - } - port - .dropRefs(d.paths) - .then((refs) => { - const took = refs.filter((r) => r.ref).map((r) => ({ path: r.path ?? "", ref: r.ref ?? "", image: r.image })); - if (took.length > 0) setShots((prev) => [...prev, ...took]); - const refused = refs.filter((r) => r.error).map((r) => r.error); - if (refused.length > 0) onError(new Error(refused.join("\n"))); - }) - .catch(onError); - }; - const drop = useFileDrop(dropped, setOver); + const discard = useCallback((c: Chip) => { + releaseChip(c); + setShots((prev) => prev.filter((x) => x !== c)); + }, []); + + const attach = useCallback( + (blobs: File[]) => { + if (blobs.length === 0) return; + Promise.all(blobs.map((b) => port.attach(b, b.name).then((a) => ({ a, url: previewURL(b) })))) + .then((added) => + setShots((prev) => [...prev, ...added.map(({ a, url }) => ({ k: "image" as const, id: chipId(), a, url }))]), + ) + .catch(onError); + }, + [port, onError], + ); + + // A dropped file is referenced where it lives. Copying it in is what let a + // turn edit the copy and report the edit as done while the file the user + // pointed at never changed. Only the host can mint the token: whether a path + // is inside the workspace compares two spellings of one location. + const refIn = useCallback( + (paths: string[]) => { + port + .dropRefs(paths) + .then((refs) => { + const took = refs.flatMap((r) => + r.ref + ? [{ k: "image" as const, id: chipId(), a: { path: r.path ?? "", ref: r.ref, image: r.image } }] + : [], + ); + if (took.length > 0) setShots((prev) => [...prev, ...took]); + const refused = refs.flatMap((r) => (r.error ? [r.error] : [])); + if (refused.length > 0) onError(new Error(refused.join("\n"))); + }) + .catch(onError); + }, + [port, onError], + ); + + const readIn = useCallback( + (files: File[]) => { + for (const file of files) { + void file + .text() + .then((body) => { + if (!body.trim()) return; + setShots((prev) => [ + ...prev, + { k: "paste", id: chipId(), body, lines: countLines(body), name: file.name }, + ]); + }) + .catch(onError); + } + }, + [onError], + ); + + // One place decides what a payload becomes, whichever channel carried it. + const receive = useCallback( + (d: Dropped) => { + if (d.paths.length > 0) refIn(d.paths); + else if (d.files.length > 0) { + const pictures = d.files.filter((f) => f.type.startsWith("image/")); + attach(pictures); + // Bytes that are not pixels have nowhere else to go: the kernel's + // attachment store keeps images only, and there is no path to point at. + // Reading them as text is the one answer left, and a good one for a + // dropped log. + readIn(d.files.filter((f) => !pictures.includes(f))); + } + if (d.text) insert(d.text + " "); + // A drop is the start of typing, not the end of it. + box.current?.focus(); + }, + [refIn, attach, readIn, insert], + ); + + const { plan: drag, ref: dropzone } = useIntake({ + root: status?.workspaceRoot ?? status?.cwd, + onReceive: receive, + }); const apv = status?.toolApprovalMode ?? "ask"; const eff = status?.effort || "auto"; @@ -151,8 +259,8 @@ export function Composer({ port, status, running, onSubmit, onChanged, onError } return ( // display:contents, so the box looks exactly as it did and the drop layer - // still has one node to ask "did this land in the composer" about. -
+ // still has one node to ask which pane a drop landed in. +
{menu.open && ( )} + {/* What letting go will do, said before it happens. A drop that only + reports afterwards is the pattern this replaces. */} + {drag && ( +

+ {planVerb(drag)} +

+ )} {shots.length > 0 && ( -
- {shots.map((a) => ( - - {a.path.split("/").pop()} - + + + )} + - + ))} {/* The kernel keeps the image either way, but a text-only model never sees it — say so here rather than letting the paste vanish. */} - {status?.vision === false && shots.some((a) => a.image) && ( - + {status?.vision === false && shots.some((c) => c.k === "image" && c.a.image !== false) && ( +
  • {status?.visionDeclared === false ? t("没人说过这个模型读不读图 · 先按不读处理;在「连接」里勾上它就直接发") : t("当前模型不读图 · 将交给能读图的子代理")} - +
  • )} -
    + )}