@spzxg/cli-ui 0.6.12 → 0.6.13

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli-ui.js CHANGED
@@ -1,4 +1,4 @@
1
- import { C as a, b as i, d as r, e as t, f as o, h as C, i as l, j as n, k as m, m as d, l as u, n as w, o as B, p as f } from "./cli-ui-embed-B0HDalUb.js";
1
+ import { C as a, b as i, d as r, e as t, f as o, h as C, i as l, j as n, k as m, m as d, l as u, n as w, o as B, p as f } from "./cli-ui-embed-CXaGT9e4.js";
2
2
  export {
3
3
  a as CliBoard,
4
4
  i as CliBrowserView,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@spzxg/cli-ui",
3
- "version": "0.6.12",
3
+ "version": "0.6.13",
4
4
  "type": "module",
5
5
  "description": "相互联动的 CliMsgList、CliChat、CliBoard,支持任务中枢和完整 Operator 工作区。",
6
6
  "main": "src/index.ts",
@@ -101,7 +101,7 @@ export interface CliBoardProps extends CliStoreOptions, WorkspaceHandlers {
101
101
  * 缺省和 quoteToChat 一样。
102
102
  */
103
103
  autoQuoteOpenFile?: boolean;
104
- /** 给了就在页签栏右侧显示「隐藏面板」按钮。 */
104
+ /** 保留旧宿主兼容;看板不再显示内置收起按钮。 */
105
105
  onCollapse?: () => void;
106
106
  /**
107
107
  * 收起状态:只显示一条窄栏(点一下展开)。收起时仍然接对话里的打开请求——接到就记下要打开的东西并调 onExpand,
@@ -125,7 +125,7 @@ export function CliBoard(props: CliBoardProps) {
125
125
 
126
126
  interface OpenFile { name?: string; reloadKey?:number; path: string; size?: number; mtime?: number; version: number }
127
127
 
128
- function BoardInner({ jobId: jobIdProp, taskId: taskIdProp, tabs, availableTabs, defaultTab, readOnly, fileActions, acceptOpen = true, quoteToChat, autoQuoteOpenFile, extraTabs, onCollapse, collapsed, onExpand, onNavigate, onEvent, onFilesChange, onFilesWatch, onBoardContextChange, watchFiles = true, fileWatchIntervalMs, theme = "light", className, style, inProvider, inlineLayout, inlineOwner, visible: visibilityOverride, onQuoteToChat }: CliBoardProps & { inProvider: boolean }) {
128
+ function BoardInner({ jobId: jobIdProp, taskId: taskIdProp, tabs, availableTabs, defaultTab, readOnly, fileActions, acceptOpen = true, quoteToChat, autoQuoteOpenFile, extraTabs, collapsed, onExpand, onNavigate, onEvent, onFilesChange, onFilesWatch, onBoardContextChange, watchFiles = true, fileWatchIntervalMs, theme = "light", className, style, inProvider, inlineLayout, inlineOwner, visible: visibilityOverride, onQuoteToChat }: CliBoardProps & { inProvider: boolean }) {
129
129
  useComponentEvents(onEvent, {onFilesChange,onFilesWatch,onBoardContextChange}, inProvider);
130
130
  const store = useCliStore();
131
131
  const backend = store.client.board;
@@ -471,7 +471,6 @@ function BoardInner({ jobId: jobIdProp, taskId: taskIdProp, tabs, availableTabs,
471
471
  {info?.dispatched && !file && <button className="rail-collapse" onClick={refreshAll} title={running ? "运行中 · 本轮结束后同步,点击立即检查" : "刷新"}>
472
472
  <span className={`cli-sync-dot ${running ? "on" : ""}`} />↻
473
473
  </button>}
474
- {onCollapse && <button className="rail-collapse" onClick={onCollapse} title="隐藏面板">{Icon.chevRight()}</button>}
475
474
  </div>;
476
475
  return <>
477
476
  {inlineLayout?.tabsHost && createPortal(tabBar, inlineLayout.tabsHost)}
@@ -188,9 +188,9 @@ function FileViewerInner({ backend, jobId, path, displayName, size, mtime, versi
188
188
  const fileReadAbort = useRef<AbortController | null>(null);
189
189
  const readingAbort = useRef<AbortController | null>(null);
190
190
  const cancelRead = () => { fileReadAbort.current?.abort(); readingAbort.current?.abort(); setSyncing(false); setParsing(false); setCancelled(true); };
191
- useEffect(() => retainFileScope(backend, jobId, { paths: [path], dirs: [], onDenied: () => {
191
+ useEffect(() => retainFileScope(backend, jobId, { paths: [path], dirs: [], onDenied: message => {
192
192
  fileReadAbort.current?.abort(); readingAbort.current?.abort(); revisionRef.current = undefined;
193
- setLoaded(null); setHtmlDoc(null); setSyncing(false); setParsing(false); setError("文件访问权限已失效,请重新登录或检查任务权限");
193
+ setLoaded(null); setHtmlDoc(null); setSyncing(false); setParsing(false); setError(message || "文件访问受限,请检查任务文件目录或访问权限");
194
194
  } }), [backend, jobId, path]);
195
195
  // 放大:盖住页面看(看板窄的时候看报告、表格、图片)。Esc 还原。
196
196
  const [big, setBig] = useState(false);
@@ -474,7 +474,7 @@ function FileViewerInner({ backend, jobId, path, displayName, size, mtime, versi
474
474
  const toolbar = <>
475
475
  {(copying || copied || busy) && <span className="cli-fv-status" role="status">{copying ? "正在复制路径…" : copied ? "路径已复制" : "正在下载…"}</span>}
476
476
  <div className="cli-fv-actions">
477
- {embedded && <button className="cli-fv-icon" title={syncing || parsing ? "正在同步当前文件" : "刷新当前文件"} aria-label="刷新当前文件" disabled={syncing || parsing} onClick={() => setAttempt(value => value + 1)}>↻</button>}
477
+ {embedded && <button className="cli-fv-icon" title={syncing || parsing ? "正在同步当前文件" : "刷新当前文件"} aria-label="刷新当前文件" aria-busy={syncing || parsing} disabled={syncing || parsing} onClick={() => setAttempt(value => value + 1)}>↻</button>}
478
478
  <button ref={moreRef} className="cli-fv-icon cli-fv-more" title="更多文件操作" aria-label="更多文件操作" aria-haspopup="dialog" aria-expanded={actionsOpen} onClick={() => setActionsOpen(open => !open)}>{Icon.dots()}</button>
479
479
  {actionsOpen && <Popover className="cli-fv-menu" ariaLabel="文件操作" returnFocusRef={moreRef} onClose={() => setActionsOpen(false)}>
480
480
  <div className="cli-fv-menu-head"><strong>文件操作</strong><span title={path}>{path}</span></div>
@@ -50,7 +50,7 @@ export function FilesPane({ backend, jobId, dir, onDir, onOpen, refreshKey, onLi
50
50
  const end = Math.min(entries?.length ?? 0, first + Math.min(80, Math.ceil(viewport / rowHeight) + 8));
51
51
  const visiblePaths = (entries ?? []).slice(first, end).filter(file => file.type === "file").map(file => file.path);
52
52
  const scopeKey = JSON.stringify(visiblePaths);
53
- useEffect(() => retainFileScope(backend, jobId, { dirs: [dir || "."], paths: JSON.parse(scopeKey) as string[], onDenied: () => { generation.current++; pageAbort.current?.abort(); setLoading(false); setMoreLoading(false); setEntries(null); setError("工作区访问权限已失效,请重新登录或检查任务权限"); } }), [backend, jobId, dir, scopeKey]);
53
+ useEffect(() => retainFileScope(backend, jobId, { dirs: [dir || "."], paths: JSON.parse(scopeKey) as string[], onDenied: message => { generation.current++; pageAbort.current?.abort(); setLoading(false); setMoreLoading(false); setEntries(null); setError(message || "工作区访问受限,请检查任务文件目录或访问权限"); } }), [backend, jobId, dir, scopeKey]);
54
54
  useEffect(() => {
55
55
  if (!listRef.current || typeof ResizeObserver === "undefined") return;
56
56
  const observer = new ResizeObserver(([box]) => { if (box.contentRect.height) setViewport(box.contentRect.height); });
@@ -1,6 +1,6 @@
1
1
  import type { BoardBackend } from "../core/client";
2
2
 
3
- type Scope = { paths: string[]; dirs: string[]; onDenied?: () => void };
3
+ type Scope = { paths: string[]; dirs: string[]; onDenied?: (message?: string) => void };
4
4
  const scopes = new WeakMap<BoardBackend, Map<string, Map<object, Scope>>>();
5
5
  const listeners = new WeakMap<BoardBackend, Map<string, Set<() => void>>>();
6
6
  /** A preview/list owns its subscription. Unmounted/closed directories are not polled. */
@@ -17,8 +17,11 @@ export function readFileScope(backend: BoardBackend, jobId: string): Scope {
17
17
  const active = [...(scopes.get(backend)?.get(jobId)?.values() ?? [])];
18
18
  return { paths: [...new Set(active.flatMap(scope => scope.paths))].sort(), dirs: [...new Set(active.flatMap(scope => scope.dirs))].sort() };
19
19
  }
20
- export function denyFileScope(backend: BoardBackend, jobId: string) {
21
- for (const scope of scopes.get(backend)?.get(jobId)?.values() ?? []) scope.onDenied?.();
20
+ export function denyFileScope(backend: BoardBackend, jobId: string, message?: string, denied?: { paths: readonly string[]; dirs: readonly string[] }) {
21
+ for (const scope of scopes.get(backend)?.get(jobId)?.values() ?? []) {
22
+ // A list owns its directory; a rejected child file must not erase that list.
23
+ if (!denied || (scope.dirs.length ? scope.dirs.some(dir => denied.dirs.includes(dir || ".")) : scope.paths.some(path => denied.paths.includes(path)))) scope.onDenied?.(message);
24
+ }
22
25
  }
23
26
  export function onFileScope(backend: BoardBackend, jobId: string, listener: () => void) {
24
27
  let jobs = listeners.get(backend); if (!jobs) { jobs = new Map(); listeners.set(backend, jobs); }
@@ -15,7 +15,7 @@ export class FileSyncError extends Error {
15
15
  }
16
16
  export interface FileSyncProgress { phase: "manifest" | "chunks" | "verify"; loadedBytes: number; totalBytes: number; downloadedBytes: number; reusedBytes: number }
17
17
  export interface FileSyncOptions { signal?: AbortSignal; onProgress?: (progress: FileSyncProgress) => void; decode?: boolean; range?: { start?: number; maxBytes: number; tail?: boolean }; pendingTimeoutMs?: number; /** Require a manifest request started after this explicit refresh. */ fresh?: boolean; /** Remember only successfully viewed ranges, never downloads. */ preview?: boolean }
18
- export interface FilePreviewRefresh { changed: string[]; error?: Error }
18
+ export interface FilePreviewRefresh { changed: string[]; error?: Error; denied?: Array<{ path: string; message: string }> }
19
19
  export interface SyncedFile {
20
20
  readonly bytes: Uint8Array<ArrayBuffer>;
21
21
  revision: string; namespace: string; hash: string; size: number; start: number; end: number; truncated: boolean;
@@ -151,7 +151,7 @@ export function createFileSynchronizer(transport: FileSyncTransport, cache = new
151
151
  if ([401, 403, 404].includes(status)) {
152
152
  last.delete(resource);
153
153
  if (previous) {
154
- if (status === 404) { const scope = { namespace: previous.namespace, taskId: jobId, path }; retireFile(scope); await cache.purgeFile(scope); }
154
+ if (status === 404 || (status === 403 && (error as FileSyncError).code === "file_forbidden")) { const scope = { namespace: previous.namespace, taskId: jobId, path }; retireFile(scope); await cache.purgeFile(scope); }
155
155
  else { retireNamespace(previous.namespace); await cache.purgeNamespace(previous.namespace); }
156
156
  }
157
157
  }
@@ -261,6 +261,7 @@ export function createFileSynchronizer(transport: FileSyncTransport, cache = new
261
261
  // prevent several 100MiB workers allocating full buffers at the same time.
262
262
  const work = async (): Promise<FilePreviewRefresh> => {
263
263
  const changed = new Set<string>(); let error: Error | undefined, hasRecoverableError = false, retryAfterMs = 0;
264
+ const denied: NonNullable<FilePreviewRefresh["denied"]> = [];
264
265
  for (const path of requested) {
265
266
  if (signal?.aborted) throw aborted();
266
267
  const resource = JSON.stringify([jobId, path]), previous = previews.get(resource);
@@ -277,7 +278,14 @@ export function createFileSynchronizer(transport: FileSyncTransport, cache = new
277
278
  }
278
279
  } catch (failure) {
279
280
  const status = statusOf(failure);
280
- if (signal?.aborted || (failure instanceof Error && failure.name === "AbortError") || status === 401 || status === 403) throw failure;
281
+ if (signal?.aborted || (failure instanceof Error && failure.name === "AbortError") || status === 401) throw failure;
282
+ if (status === 403) {
283
+ if ((failure as FileSyncError).code !== "file_forbidden") throw failure;
284
+ const scope = { namespace: last.get(resource)?.namespace ?? previous.namespace, taskId: jobId, path };
285
+ last.delete(resource); previews.delete(resource); retireFile(scope); await cache.purgeFile(scope);
286
+ changed.delete(path); denied.push({ path, message: failure instanceof Error ? failure.message : String(failure) });
287
+ continue;
288
+ }
281
289
  if (status === 404) {
282
290
  changed.add(path);
283
291
  if (previews.get(resource) === previous) {
@@ -304,7 +312,7 @@ export function createFileSynchronizer(transport: FileSyncTransport, cache = new
304
312
  }
305
313
  // Preserve the original errors: consumers may share them with other reads.
306
314
  const combined = error && hasRecoverableError && retryAfterMs > 0 ? Object.assign(new Error(error.message), error, { name: error.name, retryAfterMs }) : error;
307
- return { changed: [...changed], ...(combined ? { error: combined } : {}) };
315
+ return { changed: [...changed], ...(combined ? { error: combined } : {}), ...(denied.length ? { denied } : {}) };
308
316
  };
309
317
  return refreshQueue.run(work, signal);
310
318
  };
@@ -1,8 +1,9 @@
1
1
  import { decodeText } from "../core/textDecode";
2
+ import { sha256 } from "../core/sha256";
2
3
 
3
4
  const aborted = () => new DOMException("文件同步已取消", "AbortError");
4
5
  const integrity = (message: string) => Object.assign(new Error(message), { status: 422, code: "integrity" });
5
- const digest = async (bytes: Uint8Array<ArrayBuffer>) => [...new Uint8Array(await crypto.subtle.digest("SHA-256", bytes))].map(byte => byte.toString(16).padStart(2, "0")).join("");
6
+ const digest = async (bytes: Uint8Array<ArrayBuffer>) => globalThis.crypto?.subtle ? [...new Uint8Array(await crypto.subtle.digest("SHA-256", bytes))].map(byte => byte.toString(16).padStart(2, "0")).join("") : sha256(bytes);
6
7
  type Processed = { bytes: Uint8Array<ArrayBuffer>; text?: string; encoding?: string };
7
8
 
8
9
  /** One worker owns the assembled buffer; chunks are transferred, not copied into a second main-thread file. */
@@ -18,8 +19,8 @@ export class FileSyncProcessor {
18
19
  private failed: Error | null = null;
19
20
  constructor(private signal: AbortSignal) {
20
21
  if (typeof Worker !== "undefined" && typeof URL.createObjectURL === "function") try {
21
- const script = `const decode=${decodeText.toString()};let output=null,offset=0;
22
- const hash=async b=>Array.from(new Uint8Array(await crypto.subtle.digest('SHA-256',b)),v=>v.toString(16).padStart(2,'0')).join('');
22
+ const script = `const decode=${decodeText.toString()},fallback=${sha256.toString()};let output=null,offset=0;
23
+ const hash=async b=>globalThis.crypto?.subtle?Array.from(new Uint8Array(await crypto.subtle.digest('SHA-256',b)),v=>v.toString(16).padStart(2,'0')).join(''):fallback(b instanceof Uint8Array?b:new Uint8Array(b));
23
24
  onmessage=async({data:m})=>{try{let value;
24
25
  if(m.op==='start'){output=new Uint8Array(m.size);offset=0;value=true;}
25
26
  if(m.op==='verify'){if(await hash(m.bytes)!==m.hash)throw Error('文件块校验失败');value={bytes:m.bytes};}
@@ -43,8 +43,8 @@ export function createFileSyncTransport(fetcher: typeof fetch, pathBuilder: (job
43
43
  const retryAfterMs = retry ? (/^\d+(\.\d+)?$/.test(retry) ? Number(retry) * 1000 : Math.max(0, Date.parse(retry) - Date.now())) : undefined;
44
44
  const missingRoute = response.status === 501 || (response.status === 404 && !["file_not_found", "task_not_found", "job_not_found"].includes(error.error ?? ""));
45
45
  const code = missingRoute ? "unsupported" : error.error ?? "http_error";
46
- const common: Record<string, string> = { node_unreachable: "执行节点暂时连不上,请稍后重试", unsupported: "服务端尚未支持大文件增量同步,请升级节点和中枢", file_sync_unsupported: "服务端尚未支持大文件增量同步,请升级节点和中枢", file_not_found: "文件不存在(可能已删除或改名)", file_forbidden: "文件访问权限失效,请重新登录或确认权限", file_sync_busy: "文件服务繁忙,请稍后重试", too_many_snapshot_builds: "文件服务繁忙,请稍后重试", too_many_uploads: "上传任务较多,请稍后重试", file_sync_queue_timeout: "文件处理排队超时,请稍后重试", file_sync_timeout: "文件服务响应超时,请重试", snapshot_cache_full: "文件缓存空间不足,请稍后重试", upload_storage_full: "文件上传空间不足,请稍后重试" };
47
- const message = common[code] ?? ([401, 403].includes(response.status) ? "文件访问权限失效,请重新登录或确认权限" : error.detail ?? `文件同步失败(HTTP ${response.status})`);
46
+ const common: Record<string, string> = { node_unreachable: "执行节点暂时连不上,请稍后重试", unsupported: "服务端尚未支持大文件增量同步,请升级节点和中枢", file_sync_unsupported: "服务端尚未支持大文件增量同步,请升级节点和中枢", file_not_found: "文件不存在(可能已删除或改名)", file_forbidden: error.detail || "文件不在当前任务允许访问的目录中,或没有访问权限", file_sync_busy: "文件服务繁忙,请稍后重试", too_many_snapshot_builds: "文件服务繁忙,请稍后重试", too_many_uploads: "上传任务较多,请稍后重试", file_sync_queue_timeout: "文件处理排队超时,请稍后重试", file_sync_timeout: "文件服务响应超时,请重试", snapshot_cache_full: "文件缓存空间不足,请稍后重试", upload_storage_full: "文件上传空间不足,请稍后重试" };
47
+ const message = response.status === 401 ? "登录已失效,请重新登录" : common[code] ?? (response.status === 403 ? error.detail || "没有读取该文件的权限,请检查任务访问权限" : error.detail ?? `文件同步失败(HTTP ${response.status})`);
48
48
  throw new FileSyncError(message, response.status, code, retryAfterMs);
49
49
  }
50
50
  return { response, bytes };
@@ -78,12 +78,43 @@ export function subscribeWorkspaceFiles(backend:BoardBackend,jobId:string,listen
78
78
  const state={baseline:new Map(baseline),known:new Set(known)},nextDirectories=new Map(directories),nextCursors=new Map(cursors);
79
79
  try{
80
80
  const changes:WorkspaceFileChange[]=[];
81
+ let accessError:string|undefined;
82
+ const forbidden=(error:unknown)=>(error as {status?:number;code?:string})?.status===403&&(error as {code?:string}).code==='file_forbidden';
83
+ const deny=(error:unknown,paths:string[],dirs:string[])=>{
84
+ accessError=error instanceof Error?error.message:String(error);
85
+ denyFileScope(backend,jobId,accessError,{paths,dirs});
86
+ };
87
+ // A rejected path in a batch is not evidence that the whole task lost access.
88
+ // Inspect only that batch's metadata; healthy files and directories remain usable.
89
+ const inspect=async(paths:string[],dirs:string[])=>{
90
+ const listed=new Map<string,FileEntry>(),deniedPaths=new Set<string>();
91
+ for(const dir of dirs){
92
+ try{
93
+ const result=await requests.run(()=>backend.stat(jobId,dir||'.'),ctl.signal);if(!current())return;
94
+ if(result.type!=='dir')continue;
95
+ for(const file of result.entries)listed.set(file.path,file);
96
+ const revision=result.revision??JSON.stringify(result.entries.map(file=>[file.path,fingerprint(file)]));
97
+ if(nextDirectories.has(dir)&&nextDirectories.get(dir)!==revision)changes.push({kind:'modified',path:dir});
98
+ nextDirectories.set(dir,revision);
99
+ }catch(error){if(!current())return;if(!forbidden(error))throw error;deny(error,[],[dir||'.']);}
100
+ }
101
+ const files:FileEntry[]=[];
102
+ for(const path of paths){
103
+ const listedFile=listed.get(path);if(listedFile){files.push(listedFile);continue;}
104
+ try{const result=await requests.run(()=>backend.stat(jobId,path),ctl.signal);if(result.type==='file')files.push(result);}
105
+ catch(error){if(!current())return;if(forbidden(error)){deniedPaths.add(path);state.baseline.delete(path);state.known.delete(path);deny(error,[path],[]);}else if((error as {status?:number}).status!==404)throw error;}
106
+ if(!current())return;
107
+ }
108
+ changes.push(...snapshot(files,paths.filter(path=>!deniedPaths.has(path)),state));
109
+ };
81
110
  if(backend.pollFiles){
82
111
  const activeKeys=new Set<string>();
83
112
  const count=Math.max(Math.ceil(scope.paths.length/200),Math.ceil(scope.dirs.length/32));
84
113
  for(let index=0;index<count;index++){
85
114
  const paths=scope.paths.slice(index*200,index*200+200),dirs=scope.dirs.slice(index*32,index*32+32),key=JSON.stringify([paths,dirs]);activeKeys.add(key);
86
- const batch=await requests.run(()=>backend.pollFiles!(jobId,{paths,dirs,cursor:nextCursors.get(key)},ctl.signal),ctl.signal);
115
+ let batch:Awaited<ReturnType<NonNullable<BoardBackend['pollFiles']>>>;
116
+ try{batch=await requests.run(()=>backend.pollFiles!(jobId,{paths,dirs,cursor:nextCursors.get(key)},ctl.signal),ctl.signal);}
117
+ catch(error){if(!forbidden(error))throw error;nextCursors.delete(key);await inspect(paths,dirs);if(!current())return;continue;}
87
118
  if(!current())return;
88
119
  if(!batch||typeof batch!=='object')throw invalidResponse('文件变化响应不完整,请重试');
89
120
  if(batch.type==='resync'){cursors.delete(key);throw new Error('文件修订已变化,请重新核对');}
@@ -99,27 +130,12 @@ export function subscribeWorkspaceFiles(backend:BoardBackend,jobId:string,listen
99
130
  for(const key of nextCursors.keys())if(!activeKeys.has(key))nextCursors.delete(key);
100
131
  }else{
101
132
  // Compatibility hosts: inspect requested entries, never recurse a workspace.
102
- const listed=new Map<string,FileEntry>();
103
- for(const dir of scope.dirs){
104
- const result=await requests.run(()=>backend.stat(jobId,dir||'.'),ctl.signal);if(!current())return;
105
- if(result.type!=='dir')continue;
106
- for(const file of result.entries)listed.set(file.path,file);
107
- const revision=result.revision??JSON.stringify(result.entries.map(file=>[file.path,fingerprint(file)]));
108
- if(nextDirectories.has(dir)&&nextDirectories.get(dir)!==revision)changes.push({kind:'modified',path:dir});
109
- nextDirectories.set(dir,revision);
110
- }
111
- const files:FileEntry[]=[];
112
- for(const path of scope.paths){
113
- const listedFile=listed.get(path);if(listedFile){files.push(listedFile);continue;}
114
- try{const result=await requests.run(()=>backend.stat(jobId,path),ctl.signal);if(result.type==='file')files.push(result);}
115
- catch(error){if((error as {status?:number}).status!==404)throw error;}
116
- if(!current())return;
117
- }
118
- changes.push(...snapshot(files,scope.paths,state));
133
+ await inspect(scope.paths,scope.dirs);
119
134
  }
120
135
  if(!current())return;
121
136
  const previews=backend.fileSync?await refreshFilePreviews(backend.fileSync,jobId,scope.paths,ctl.signal):undefined;
122
137
  if(!current())return;
138
+ for(const file of previews?.denied??[])deny(new Error(file.message),[file.path],[]);
123
139
  const changedPaths=new Set(changes.map(change=>change.path));
124
140
  for(const path of previews?.changed??[])if(!changedPaths.has(path)){changes.push({kind:'modified',path});changedPaths.add(path);}
125
141
  const active=new Set(scope.paths);for(const path of state.known)if(!active.has(path)){state.known.delete(path);state.baseline.delete(path);}
@@ -127,7 +143,7 @@ export function subscribeWorkspaceFiles(backend:BoardBackend,jobId:string,listen
127
143
  baseline=state.baseline;known=state.known;directories=nextDirectories;cursors=nextCursors;
128
144
  publish(changes);
129
145
  if(previews?.error)throw previews.error;
130
- failed=0;serverNotBefore=0;retryAt=0;exhaustedUntil=0;if(current())status('ready');
146
+ failed=0;serverNotBefore=0;retryAt=0;exhaustedUntil=0;if(current())status(accessError?'error':'ready',accessError);
131
147
  }catch(error){if(current()){
132
148
  failed++;const code=(error as {status?:number}).status??0;
133
149
  const failureCode=(error as {code?:string}).code;
@@ -139,7 +155,7 @@ export function subscribeWorkspaceFiles(backend:BoardBackend,jobId:string,listen
139
155
  retryAt=Math.max(Date.now()+base,serverNotBefore)+Math.floor(Math.random()*base/2);
140
156
  if(failed>retryDelays.length)exhaustedUntil=Math.max(Date.now()+30000,serverNotBefore);
141
157
  }
142
- if([401,403].includes(code))denyFileScope(backend,jobId);
158
+ if([401,403].includes(code))denyFileScope(backend,jobId,error instanceof Error?error.message:undefined);
143
159
  status('error',error instanceof Error?error.message:String(error));
144
160
  }}
145
161
  finally{if(controller===ctl){
@@ -1,22 +1,10 @@
1
1
  import type { CenterClientOptions } from './client';
2
2
  import { clientId } from './id';
3
+ import { sha256 } from './sha256';
3
4
  // A synchronous SHA-256 fingerprint partitions persistent drafts without putting
4
5
  // credentials in localStorage keys. It is never used for authentication.
5
- const K = [0x428a2f98,0x71374491,0xb5c0fbcf,0xe9b5dba5,0x3956c25b,0x59f111f1,0x923f82a4,0xab1c5ed5,0xd807aa98,0x12835b01,0x243185be,0x550c7dc3,0x72be5d74,0x80deb1fe,0x9bdc06a7,0xc19bf174,0xe49b69c1,0xefbe4786,0x0fc19dc6,0x240ca1cc,0x2de92c6f,0x4a7484aa,0x5cb0a9dc,0x76f988da,0x983e5152,0xa831c66d,0xb00327c8,0xbf597fc7,0xc6e00bf3,0xd5a79147,0x06ca6351,0x14292967,0x27b70a85,0x2e1b2138,0x4d2c6dfc,0x53380d13,0x650a7354,0x766a0abb,0x81c2c92e,0x92722c85,0xa2bfe8a1,0xa81a664b,0xc24b8b70,0xc76c51a3,0xd192e819,0xd6990624,0xf40e3585,0x106aa070,0x19a4c116,0x1e376c08,0x2748774c,0x34b0bcb5,0x391c0cb3,0x4ed8aa4a,0x5b9cca4f,0x682e6ff3,0x748f82ee,0x78a5636f,0x84c87814,0x8cc70208,0x90befffa,0xa4506ceb,0xbef9a3f7,0xc67178f2];
6
- const rotate = (x:number,n:number)=>(x>>>n)|(x<<(32-n));
7
6
  export function connectionFingerprint(text:string):string {
8
- const raw=new TextEncoder().encode(text),data=new Uint8Array(Math.ceil((raw.length+9)/64)*64);data.set(raw);data[raw.length]=128;
9
- const view=new DataView(data.buffer);view.setUint32(data.length-8,Math.floor(raw.length/0x20000000));view.setUint32(data.length-4,(raw.length*8)>>>0);
10
- const hash=[0x6a09e667,0xbb67ae85,0x3c6ef372,0xa54ff53a,0x510e527f,0x9b05688c,0x1f83d9ab,0x5be0cd19],w=new Int32Array(64);
11
- for(let offset=0;offset<data.length;offset+=64){
12
- for(let i=0;i<16;i++)w[i]=view.getInt32(offset+i*4);
13
- for(let i=16;i<64;i++){const x=w[i-15],y=w[i-2];w[i]=(w[i-16]+(rotate(x,7)^rotate(x,18)^(x>>>3))+w[i-7]+(rotate(y,17)^rotate(y,19)^(y>>>10)))|0;}
14
- let [a,b,c,d,e,f,g,h]=hash;
15
- for(let i=0;i<64;i++){const t1=(h+(rotate(e,6)^rotate(e,11)^rotate(e,25))+((e&f)^(~e&g))+K[i]+w[i])|0,t2=((rotate(a,2)^rotate(a,13)^rotate(a,22))+((a&b)^(a&c)^(b&c)))|0;
16
- h=g;g=f;f=e;e=(d+t1)|0;d=c;c=b;b=a;a=(t1+t2)|0;}
17
- [a,b,c,d,e,f,g,h].forEach((value,i)=>{hash[i]=(hash[i]+value)|0;});
18
- }
19
- return hash.map(value=>(value>>>0).toString(16).padStart(8,'0')).join('');
7
+ return sha256(new TextEncoder().encode(text));
20
8
  }
21
9
  const customScopes=new WeakMap<object,string>();
22
10
  export function connectionDraftScope(options:CenterClientOptions&{namespace?:string|null;client?:object}):string {
@@ -0,0 +1,21 @@
1
+ /** Keep this byte-only fallback self-contained so file workers can use the same implementation. */
2
+ export function sha256(bytes: Uint8Array): string {
3
+ const K = [0x428a2f98,0x71374491,0xb5c0fbcf,0xe9b5dba5,0x3956c25b,0x59f111f1,0x923f82a4,0xab1c5ed5,0xd807aa98,0x12835b01,0x243185be,0x550c7dc3,0x72be5d74,0x80deb1fe,0x9bdc06a7,0xc19bf174,0xe49b69c1,0xefbe4786,0x0fc19dc6,0x240ca1cc,0x2de92c6f,0x4a7484aa,0x5cb0a9dc,0x76f988da,0x983e5152,0xa831c66d,0xb00327c8,0xbf597fc7,0xc6e00bf3,0xd5a79147,0x06ca6351,0x14292967,0x27b70a85,0x2e1b2138,0x4d2c6dfc,0x53380d13,0x650a7354,0x766a0abb,0x81c2c92e,0x92722c85,0xa2bfe8a1,0xa81a664b,0xc24b8b70,0xc76c51a3,0xd192e819,0xd6990624,0xf40e3585,0x106aa070,0x19a4c116,0x1e376c08,0x2748774c,0x34b0bcb5,0x391c0cb3,0x4ed8aa4a,0x5b9cca4f,0x682e6ff3,0x748f82ee,0x78a5636f,0x84c87814,0x8cc70208,0x90befffa,0xa4506ceb,0xbef9a3f7,0xc67178f2];
4
+ const rotate = (x:number,n:number)=>(x>>>n)|(x<<(32-n));
5
+ // Only the final one or two blocks need padding; do not copy an entire large file.
6
+ const blockBytes=bytes.length-bytes.length%64,tail=new Uint8Array(bytes.length%64<56?64:128);
7
+ tail.set(bytes.subarray(blockBytes));tail[bytes.length%64]=128;
8
+ const input=new DataView(bytes.buffer,bytes.byteOffset,bytes.byteLength),padding=new DataView(tail.buffer);
9
+ padding.setUint32(tail.length-8,Math.floor(bytes.length/0x20000000));padding.setUint32(tail.length-4,(bytes.length*8)>>>0);
10
+ const hash=[0x6a09e667,0xbb67ae85,0x3c6ef372,0xa54ff53a,0x510e527f,0x9b05688c,0x1f83d9ab,0x5be0cd19],w=new Int32Array(64);
11
+ for(let offset=0;offset<blockBytes+tail.length;offset+=64){
12
+ const view=offset<blockBytes?input:padding,base=offset<blockBytes?offset:offset-blockBytes;
13
+ for(let i=0;i<16;i++)w[i]=view.getInt32(base+i*4);
14
+ for(let i=16;i<64;i++){const x=w[i-15],y=w[i-2];w[i]=(w[i-16]+(rotate(x,7)^rotate(x,18)^(x>>>3))+w[i-7]+(rotate(y,17)^rotate(y,19)^(y>>>10)))|0;}
15
+ let [a,b,c,d,e,f,g,h]=hash;
16
+ for(let i=0;i<64;i++){const t1=(h+(rotate(e,6)^rotate(e,11)^rotate(e,25))+((e&f)^(~e&g))+K[i]+w[i])|0,t2=((rotate(a,2)^rotate(a,13)^rotate(a,22))+((a&b)^(a&c)^(b&c)))|0;
17
+ h=g;g=f;f=e;e=(d+t1)|0;d=c;c=b;b=a;a=(t1+t2)|0;}
18
+ [a,b,c,d,e,f,g,h].forEach((value,i)=>{hash[i]=(hash[i]+value)|0;});
19
+ }
20
+ return hash.map(value=>(value>>>0).toString(16).padStart(8,'0')).join('');
21
+ }
@@ -88,7 +88,9 @@ export function ColResize({ min, max, onWidth, onReset, side = "left" }: { min:
88
88
  const move = (e: MouseEvent) => {
89
89
  if (!dragging.current) return;
90
90
  if (side === "right") {
91
- const col = ref.current?.nextElementSibling as HTMLElement | null;
91
+ // WorkspaceDrawer keeps display:contents wrappers on desktop; measure the visible rail.
92
+ const next = ref.current?.nextElementSibling as HTMLElement | null;
93
+ const col = next?.querySelector<HTMLElement>(".sess-rail") ?? next;
92
94
  if (!col) return;
93
95
  const right = col.getBoundingClientRect().right;
94
96
  onWidthRef.current(Math.max(min, Math.min(max, Math.round(right - e.clientX))));
@@ -122,7 +122,7 @@ function ContextPane({ task, sessions, running, onClear }: { task: TaskRow; sess
122
122
  );
123
123
  }
124
124
 
125
- export function SessionRail({ project, task, sessions, running, onResolveWithAI, onMerged, onPrCreated, onClear, onCollapse, onSwitchToChat,
125
+ export function SessionRail({ project, task, sessions, running, onResolveWithAI, onMerged, onPrCreated, onClear, onSwitchToChat,
126
126
  tabs, availableTabs, defaultTab, autoContext, watchFiles = true, fileWatchIntervalMs, onFilesChange, onFilesWatch, onBoardContextChange, onEvent, openRequest, onRequestHandled, onQuote, inlineLayout, onExpand, visible = true }: OperatorBoardOptions & {
127
127
  project: ProjectRow; task: TaskRow; sessions: Session[]; running: boolean;
128
128
  onResolveWithAI: (taskId: string) => Promise<ResolveResult>; onMerged?: () => void;
@@ -268,7 +268,7 @@ export function SessionRail({ project, task, sessions, running, onResolveWithAI,
268
268
  </div>
269
269
  <PanelControls toolbarRef={setToolbarHost} available={available} open={panels.open} label={label} onShow={id=>{panels.show(id);setTab(id);inlineLayout?.onActivate();}} onClose={close}
270
270
  upload={available.includes('files')?{onChoose:upload.choose,disabled:upload.busy||!uploader,hint:canUpload?undefined:'启动会话并创建工作区后可上传文件'}:undefined}/>
271
- {inlineLayout ? onExpand&&<button className="rail-collapse" onClick={onExpand} title="展开右侧看板" aria-label="展开看板">{Icon.chevRight()}</button> : <button className="rail-collapse" onClick={onCollapse} title="隐藏面板" aria-label="隐藏看板">{Icon.chevRight()}</button>}
271
+ {inlineLayout && onExpand && <button className="rail-collapse" onClick={onExpand} title="展开右侧看板" aria-label="展开看板">{Icon.chevRight()}</button>}
272
272
  </div>;
273
273
  return <aside className={`sess-rail${inlineLayout?' sess-rail-inline':''}`} aria-label="任务看板" hidden={!contentVisible} style={!contentVisible?{display:'none'}:undefined} onPointerDown={()=>activateBoardState(task.id,owner)} onClickCapture={()=>activateBoardState(task.id,owner)} onFocusCapture={()=>activateBoardState(task.id,owner)}>
274
274
  {inlineLayout ? inlineLayout.tabsHost&&createPortal(railTabs,inlineLayout.tabsHost) : railTabs}
@@ -1871,6 +1871,12 @@ button.pop-item:hover{background:var(--surface-2);}
1871
1871
  [data-chat-layout]>.cli-refs,[data-chat-layout]>.cli-queue{width:calc(100% - 2 * var(--cli-chat-gutter) - var(--cli-chat-scrollbar,0px));max-width:var(--cli-chat-content-max);margin-inline:auto;margin-inline-start:max(var(--cli-chat-gutter),calc((100% - var(--cli-chat-scrollbar,0px) - var(--cli-chat-content-max)) / 2));flex-shrink:0;}
1872
1872
  @container chat-column (max-width:900px){[data-chat-layout]>*{--cli-chat-content-max:100%;}}
1873
1873
  @container chat-column (max-width:640px){[data-chat-layout]>*{--cli-chat-gutter:12px;}.session [data-chat-layout]>.composer{padding-inline-end:var(--cli-chat-gutter);}}
1874
+ @media (min-width:761px){
1875
+ [data-chat-layout]{--cli-chat-gutter:32px;}
1876
+ .session [data-chat-layout] .composer{padding-inline:var(--cli-chat-gutter);padding-inline-end:calc(var(--cli-chat-gutter) + var(--cli-chat-scrollbar,0px));}
1877
+ [data-chat-layout] .composer>.composer-inner{width:100%;max-width:var(--cli-chat-content-max);margin-inline:auto;}
1878
+ @container chat-column (max-width:640px){[data-chat-layout]>*{--cli-chat-gutter:24px;}.session [data-chat-layout] .composer{padding-inline-end:var(--cli-chat-gutter);}}
1879
+ }
1874
1880
  .task-origin{display:flex;align-items:center;gap:5px;font-size:10px;color:var(--ink-3);padding-top:7px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
1875
1881
  .fork-sources{display:flex;flex-wrap:wrap;gap:6px;padding:12px 14px 0;}
1876
1882
  .fork-sources>span{max-width:100%;display:flex;align-items:center;gap:6px;padding:6px 8px;border:1px solid var(--line);background:var(--surface-2);border-radius:8px;font-size:11px;color:var(--ink-2);overflow-wrap:anywhere;}
package/src/operator.css CHANGED
@@ -1871,6 +1871,12 @@
1871
1871
  .cli-ui [data-chat-layout]>.cli-refs,.cli-ui [data-chat-layout]>.cli-queue{width:calc(100% - 2 * var(--cli-chat-gutter) - var(--cli-chat-scrollbar,0px));max-width:var(--cli-chat-content-max);margin-inline:auto;margin-inline-start:max(var(--cli-chat-gutter),calc((100% - var(--cli-chat-scrollbar,0px) - var(--cli-chat-content-max)) / 2));flex-shrink:0;}
1872
1872
  @container chat-column (max-width:900px){.cli-ui [data-chat-layout]>*{--cli-chat-content-max:100%;}}
1873
1873
  @container chat-column (max-width:640px){.cli-ui [data-chat-layout]>*{--cli-chat-gutter:12px;}.cli-ui .session [data-chat-layout]>.composer{padding-inline-end:var(--cli-chat-gutter);}}
1874
+ @media (min-width:761px){
1875
+ .cli-ui [data-chat-layout]{--cli-chat-gutter:32px;}
1876
+ .cli-ui .session [data-chat-layout] .composer{padding-inline:var(--cli-chat-gutter);padding-inline-end:calc(var(--cli-chat-gutter) + var(--cli-chat-scrollbar,0px));}
1877
+ .cli-ui [data-chat-layout] .composer>.composer-inner{width:100%;max-width:var(--cli-chat-content-max);margin-inline:auto;}
1878
+ @container chat-column (max-width:640px){.cli-ui [data-chat-layout]>*{--cli-chat-gutter:24px;}.cli-ui .session [data-chat-layout] .composer{padding-inline-end:var(--cli-chat-gutter);}}
1879
+ }
1874
1880
  .cli-ui .task-origin{display:flex;align-items:center;gap:5px;font-size:10px;color:var(--ink-3);padding-top:7px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
1875
1881
  .cli-ui .fork-sources{display:flex;flex-wrap:wrap;gap:6px;padding:12px 14px 0;}
1876
1882
  .cli-ui .fork-sources>span{max-width:100%;display:flex;align-items:center;gap:6px;padding:6px 8px;border:1px solid var(--line);background:var(--surface-2);border-radius:8px;font-size:11px;color:var(--ink-2);overflow-wrap:anywhere;}