@xgspz/cli-ui 0.0.0-stage → 0.6.14
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/CHANGELOG.md +156 -0
- package/README.md +512 -2
- package/dist/cli-ui-embed-D-3DvyTN.js +50279 -0
- package/dist/cli-ui-index-C34qFb4u.js +11209 -0
- package/dist/cli-ui-jszip.min-BObcE2nn.js +26 -0
- package/dist/cli-ui-jszip.min-DojbZPiE.js +2324 -0
- package/dist/cli-ui-xlsx-JVLtx0qR.js +93 -0
- package/dist/cli-ui.css +32 -0
- package/dist/cli-ui.js +17 -0
- package/package.json +60 -4
- package/src/board/BrowserPane.tsx +969 -0
- package/src/board/ChangesPane.tsx +185 -0
- package/src/board/CliBoard.tsx +484 -0
- package/src/board/FileViewer.tsx +721 -0
- package/src/board/FilesPane.tsx +262 -0
- package/src/board/HtmlReadingPreview.tsx +52 -0
- package/src/board/MarkdownPreview.tsx +134 -0
- package/src/board/PanelControls.tsx +40 -0
- package/src/board/blocks.tsx +97 -0
- package/src/board/context.ts +65 -0
- package/src/board/diffLines.ts +46 -0
- package/src/board/fileKind.ts +250 -0
- package/src/board/fileOps.ts +85 -0
- package/src/board/fileScope.ts +32 -0
- package/src/board/fileSync.ts +351 -0
- package/src/board/fileSyncCache.ts +250 -0
- package/src/board/fileSyncProcessor.ts +72 -0
- package/src/board/fileSyncQueue.ts +56 -0
- package/src/board/fileSyncTransport.ts +85 -0
- package/src/board/fileWatch.ts +211 -0
- package/src/board/folderDownload.ts +211 -0
- package/src/board/frameQuote.ts +52 -0
- package/src/board/htmlReading.ts +148 -0
- package/src/board/panels.ts +33 -0
- package/src/board/textLoad.ts +76 -0
- package/src/board/useBoardUpload.tsx +51 -0
- package/src/board/useBoardVisibility.ts +35 -0
- package/src/board/workspace.ts +200 -0
- package/src/board/zip.ts +85 -0
- package/src/chat/CliChat.tsx +932 -0
- package/src/chat/Composer.tsx +239 -0
- package/src/chat/GroupChat.tsx +113 -0
- package/src/chat/RunControls.tsx +196 -0
- package/src/chat/configuration.ts +15 -0
- package/src/chat/draft.ts +49 -0
- package/src/chat/export.ts +34 -0
- package/src/chat/queueEdit.ts +27 -0
- package/src/chat/useExecutionConfig.ts +93 -0
- package/src/components.tsx +13 -0
- package/src/core/blocks.ts +205 -0
- package/src/core/callbacks.ts +8 -0
- package/src/core/client.ts +822 -0
- package/src/core/config.ts +252 -0
- package/src/core/conversation.ts +577 -0
- package/src/core/documentUpload.ts +196 -0
- package/src/core/execution.ts +24 -0
- package/src/core/id.ts +15 -0
- package/src/core/identity.ts +21 -0
- package/src/core/provider.tsx +141 -0
- package/src/core/sha256.ts +21 -0
- package/src/core/sse.ts +51 -0
- package/src/core/store.ts +1103 -0
- package/src/core/subagents.ts +78 -0
- package/src/core/textDecode.ts +24 -0
- package/src/core/types.ts +505 -0
- package/src/core/xlsx.ts +142 -0
- package/src/elements.tsx +376 -0
- package/src/embed.ts +56 -0
- package/src/index.ts +56 -0
- package/src/layout/CliSplit.tsx +53 -0
- package/src/layout/split.ts +251 -0
- package/src/msglist/CliMsgList.tsx +422 -0
- package/src/operator/branchFlow.ts +35 -0
- package/src/operator/index.ts +9 -0
- package/src/operator/liveStream.ts +1 -0
- package/src/operator/node/AgentConnect.tsx +619 -0
- package/src/operator/node/AppearancePanel.tsx +35 -0
- package/src/operator/node/BranchHistory.tsx +67 -0
- package/src/operator/node/BranchNavigator.tsx +36 -0
- package/src/operator/node/BrowserPane.tsx +128 -0
- package/src/operator/node/BrowserPopup.tsx +22 -0
- package/src/operator/node/Chat.tsx +538 -0
- package/src/operator/node/CommandPalette.tsx +213 -0
- package/src/operator/node/Composer.tsx +316 -0
- package/src/operator/node/InsightsView.tsx +831 -0
- package/src/operator/node/Layout.tsx +192 -0
- package/src/operator/node/Markdown.tsx +61 -0
- package/src/operator/node/Modal.tsx +179 -0
- package/src/operator/node/NeedsYouMenu.tsx +54 -0
- package/src/operator/node/OnboardingWizard.tsx +248 -0
- package/src/operator/node/Orchestrator.tsx +644 -0
- package/src/operator/node/ProjectLanding.tsx +86 -0
- package/src/operator/node/ProjectsColumn.tsx +137 -0
- package/src/operator/node/ProviderAuth.tsx +559 -0
- package/src/operator/node/RunControls.tsx +2 -0
- package/src/operator/node/RunSwitch.tsx +113 -0
- package/src/operator/node/Services.tsx +245 -0
- package/src/operator/node/SessionRail.tsx +299 -0
- package/src/operator/node/SessionView.tsx +292 -0
- package/src/operator/node/SettingsView.tsx +690 -0
- package/src/operator/node/TaskBoard.tsx +352 -0
- package/src/operator/node/TaskChanges.tsx +526 -0
- package/src/operator/node/TaskHero.tsx +50 -0
- package/src/operator/node/TasksColumn.tsx +200 -0
- package/src/operator/node/Terminal.tsx +143 -0
- package/src/operator/node/Transcript.tsx +485 -0
- package/src/operator/node/ViewFile.tsx +79 -0
- package/src/operator/node/Welcome.tsx +101 -0
- package/src/operator/node/WorktreePrune.tsx +333 -0
- package/src/operator/node/agents.ts +58 -0
- package/src/operator/node/api.ts +57 -0
- package/src/operator/node/boardBackend.ts +63 -0
- package/src/operator/node/boardEvents.ts +40 -0
- package/src/operator/node/boardInteraction.ts +15 -0
- package/src/operator/node/boardState.ts +74 -0
- package/src/operator/node/boardWorkspace.ts +15 -0
- package/src/operator/node/boundedPoll.ts +47 -0
- package/src/operator/node/branchFlow.ts +1 -0
- package/src/operator/node/composerDraft.ts +78 -0
- package/src/operator/node/fileLinks.ts +81 -0
- package/src/operator/node/format.ts +292 -0
- package/src/operator/node/github.tsx +232 -0
- package/src/operator/node/icons.tsx +88 -0
- package/src/operator/node/liveStream.ts +323 -0
- package/src/operator/node/modals.tsx +766 -0
- package/src/operator/node/navHistory.ts +70 -0
- package/src/operator/node/persist.ts +26 -0
- package/src/operator/node/protocol/agents/optIn.ts +9 -0
- package/src/operator/node/protocol/agents/types.ts +394 -0
- package/src/operator/node/protocol/approvalFailure.ts +77 -0
- package/src/operator/node/protocol/authFailure.ts +66 -0
- package/src/operator/node/protocol/customModels.ts +23 -0
- package/src/operator/node/protocol/events.ts +13 -0
- package/src/operator/node/protocol/features.ts +70 -0
- package/src/operator/node/protocol/internalUsage.ts +1 -0
- package/src/operator/node/protocol/promptLimits.ts +52 -0
- package/src/operator/node/protocol/storageRetention.ts +13 -0
- package/src/operator/node/protocol/types.ts +335 -0
- package/src/operator/node/protocol/usageLimit.ts +72 -0
- package/src/operator/node/shared.tsx +89 -0
- package/src/operator/node/transcriptModel.ts +129 -0
- package/src/operator/node/types.ts +305 -0
- package/src/operator/node/useGlobalEvents.ts +97 -0
- package/src/operator/node/useOrchestrator.ts +697 -0
- package/src/operator/node/usePrefs.ts +114 -0
- package/src/operator/node/useRecaps.ts +102 -0
- package/src/operator/node/useTaskStream.ts +286 -0
- package/src/operator.base.css +1949 -0
- package/src/operator.css +1949 -0
- package/src/styles.css +579 -0
- package/src/ui/ErrorBoundary.tsx +38 -0
- package/src/ui/Markdown.tsx +60 -0
- package/src/ui/Popover.tsx +95 -0
- package/src/ui/QueuedMessages.tsx +110 -0
- package/src/ui/ReferenceCard.tsx +28 -0
- package/src/ui/SubagentChat.tsx +233 -0
- package/src/ui/TextBlock.tsx +51 -0
- package/src/ui/Transcript.tsx +435 -0
- package/src/ui/UploadProgress.tsx +46 -0
- package/src/ui/WorkspaceDrawer.tsx +64 -0
- package/src/ui/WorkspaceTabs.tsx +60 -0
- package/src/ui/fileLinks.ts +52 -0
- package/src/ui/format.ts +103 -0
- package/src/ui/icons.tsx +92 -0
- package/src/ui/reference.ts +40 -0
- package/src/ui/shared.tsx +38 -0
- package/src/ui/transcriptModel.ts +151 -0
- package/src/ui/useChatScrollbar.ts +29 -0
- package/src/ui/useComposerAutosize.ts +54 -0
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { createContext } from 'react';
|
|
2
|
+
import type { WorkspaceHandlers } from '../../core/provider';
|
|
3
|
+
import type { CliEvent } from '../../core/store';
|
|
4
|
+
import type { Quote } from './composerDraft';
|
|
5
|
+
export type OperatorBoardTab = 'files' | 'changes' | 'context' | 'preview' | 'browser';
|
|
6
|
+
export interface OperatorBoardOptions extends WorkspaceHandlers { watchFiles?:boolean; fileWatchIntervalMs?:number; onEvent?:(event:CliEvent)=>void; tabs?: OperatorBoardTab[]; availableTabs?: OperatorBoardTab[]; defaultTab?: OperatorBoardTab; autoContext?: boolean }
|
|
7
|
+
export type OperatorBoardTarget = { kind: 'file'; taskId: string; path: string } | { kind: 'browser'; taskId: string };
|
|
8
|
+
export interface OperatorBoardRequest { sequence: number; target: OperatorBoardTarget }
|
|
9
|
+
export const OperatorBoardContext = createContext<((target: OperatorBoardTarget) => boolean) | null>(null);
|
|
10
|
+
|
|
11
|
+
export const OperatorBoardQuoteContext = createContext<((quote:Quote)=>void)|null>(null);
|
|
12
|
+
|
|
13
|
+
const surfaces=new Set<(target:OperatorBoardTarget)=>boolean>();
|
|
14
|
+
export function registerOperatorBoard(surface:(target:OperatorBoardTarget)=>boolean):()=>void {surfaces.add(surface);return()=>{surfaces.delete(surface);};}
|
|
15
|
+
export function openOperatorBoard(target:OperatorBoardTarget):boolean {for(const surface of [...surfaces].reverse())if(surface(target))return true;return false;}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { useSyncExternalStore } from 'react';
|
|
2
|
+
import type { BoardFile, BoardView } from '../../board/workspace';
|
|
3
|
+
import { rememberBoardFile, cloneBoardView, reportBoardOwner, activateBoardOwner, selectedBoardOwner } from '../../board/context';
|
|
4
|
+
import { notifyCallback } from '../../core/callbacks';
|
|
5
|
+
const cache=new Map<string,{stamp:string;view:BoardView|null}>();
|
|
6
|
+
const listeners=new Map<string,Set<()=>void>>();
|
|
7
|
+
const owners=new Map<string,Map<string,BoardView>>();
|
|
8
|
+
// Mounted directory identity is transient; it must not become retained browsing history.
|
|
9
|
+
const directorySources=new Map<string,Map<string,string>>();
|
|
10
|
+
const key=(id:string)=>`cli-ui:operator-board:${id}`;
|
|
11
|
+
const stamp=(id:string)=>{try{return sessionStorage.getItem(key(id))??'';}catch{return 'blocked';}};
|
|
12
|
+
const validFile=(value:unknown):value is BoardFile=>!!value&&typeof value==='object'&&typeof (value as BoardFile).path==='string'&&!!(value as BoardFile).path&&((value as BoardFile).sourceId===undefined||typeof (value as BoardFile).sourceId==='string');
|
|
13
|
+
const recent=(items:BoardFile[]=[])=>{
|
|
14
|
+
return items.filter(validFile).reduce((history,file)=>rememberBoardFile(history,file),[] as BoardFile[]);
|
|
15
|
+
};
|
|
16
|
+
export function readBoardState(taskId:string):BoardView|null {
|
|
17
|
+
const saved=stamp(taskId),known=cache.get(taskId);if(known?.stamp===saved)return known.view;
|
|
18
|
+
let view:BoardView|null=null;
|
|
19
|
+
try{
|
|
20
|
+
const value=JSON.parse(saved||'null');
|
|
21
|
+
if(value&&typeof value.tab==='string'&&typeof value.dir==='string'&&Array.isArray(value.files))view={...value,file:typeof value.file==='string'?value.file:null,files:value.files.filter((path:unknown)=>typeof path==='string'),current:validFile(value.current)?{...value.current}:null,opened:recent(value.opened),history:recent(value.history),visible:false};
|
|
22
|
+
}catch{/* Broken preferences cannot stop the workspace. */}
|
|
23
|
+
cache.set(taskId,{stamp:saved,view});return view;
|
|
24
|
+
}
|
|
25
|
+
/** Internal: background refresh covers every visible owner, not just the selected board. */
|
|
26
|
+
export function readVisibleBoardSources(taskId:string):string[] {
|
|
27
|
+
const active=owners.get(taskId);
|
|
28
|
+
const views=active?.size?[...active]:[['',readBoardState(taskId)] as const];
|
|
29
|
+
const sources=new Set<string>();
|
|
30
|
+
for(const [owner,view] of views){
|
|
31
|
+
if(!view||view.visible===false)continue;
|
|
32
|
+
for(const file of view.opened??[])sources.add(file.sourceId??taskId);
|
|
33
|
+
const directory=directorySources.get(taskId)?.get(owner);if(directory)sources.add(directory);
|
|
34
|
+
}
|
|
35
|
+
return [...sources];
|
|
36
|
+
}
|
|
37
|
+
export function subscribeBoardState(taskId:string,listener:()=>void):()=>void {
|
|
38
|
+
const set=listeners.get(taskId)??new Set<()=>void>();set.add(listener);listeners.set(taskId,set);return()=>{set.delete(listener);if(!set.size)listeners.delete(taskId);};
|
|
39
|
+
}
|
|
40
|
+
export function publishBoardState(taskId:string,view:BoardView):void {
|
|
41
|
+
const next=cloneBoardView({...view,current:view.current??null,opened:recent(view.opened),history:recent(view.history)});
|
|
42
|
+
if(JSON.stringify(readBoardState(taskId))===JSON.stringify(next))return;
|
|
43
|
+
try{sessionStorage.setItem(key(taskId),JSON.stringify(next));}catch{/* In-memory state still reaches Chat. */}
|
|
44
|
+
cache.set(taskId,{stamp:stamp(taskId),view:next});for(const listener of [...listeners.get(taskId)??[]])notifyCallback(listener,undefined);
|
|
45
|
+
}
|
|
46
|
+
export function useBoardState(taskId:string):BoardView|null {
|
|
47
|
+
return useSyncExternalStore(listener=>subscribeBoardState(taskId,listener),()=>readBoardState(taskId),()=>null);
|
|
48
|
+
}
|
|
49
|
+
export function visitBoardFile(taskId:string,file:BoardFile):void {
|
|
50
|
+
const current=readBoardState(taskId)??{tab:'files',dir:'',file:null,files:[]};
|
|
51
|
+
publishBoardState(taskId,{...current,history:recent([...(current.history??[]),file])});
|
|
52
|
+
}
|
|
53
|
+
export function reportBoardState(taskId:string,owner:string,view:BoardView,directorySource?:string):void {
|
|
54
|
+
const sources=owners.get(taskId)??new Map<string,BoardView>();
|
|
55
|
+
if(directorySource){const directories=directorySources.get(taskId)??new Map<string,string>();directories.set(owner,directorySource);directorySources.set(taskId,directories);}
|
|
56
|
+
else{const directories=directorySources.get(taskId);directories?.delete(owner);if(!directories?.size)directorySources.delete(taskId);}
|
|
57
|
+
const history=readBoardState(taskId)?.history??view.history??[];
|
|
58
|
+
const selected=reportBoardOwner(sources,owner,{...view,history});owners.set(taskId,sources);
|
|
59
|
+
if(selected)publishBoardState(taskId,{...selected[1],history});
|
|
60
|
+
}
|
|
61
|
+
export function activateBoardState(taskId:string,owner:string):void {
|
|
62
|
+
const sources=owners.get(taskId);if(!sources)return;
|
|
63
|
+
const selected=activateBoardOwner(sources,owner);if(!selected)return;
|
|
64
|
+
const previous=readBoardState(taskId),file=selected[1].current;
|
|
65
|
+
const history=file?rememberBoardFile(previous?.history??[],file):previous?.history??[];
|
|
66
|
+
publishBoardState(taskId,{...selected[1],history});
|
|
67
|
+
}
|
|
68
|
+
export function releaseBoardState(taskId:string,owner:string):void {
|
|
69
|
+
const sources=owners.get(taskId);sources?.delete(owner);if(!sources?.size)owners.delete(taskId);
|
|
70
|
+
const directories=directorySources.get(taskId);directories?.delete(owner);if(!directories?.size)directorySources.delete(taskId);
|
|
71
|
+
const previous=readBoardState(taskId);if(!previous)return;
|
|
72
|
+
const remaining=sources?.size?selectedBoardOwner(sources)?.[1]:null;
|
|
73
|
+
publishBoardState(taskId,remaining?{...remaining,history:previous.history,visible:remaining.visible!==false}:{...previous,visible:false});
|
|
74
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { WorkspaceRefreshOptions } from '../../board/fileWatch';
|
|
2
|
+
import { createWorkspace } from '../../board/workspace';
|
|
3
|
+
import { cloneBoardView } from '../../board/context';
|
|
4
|
+
import { getOperatorBoardBackend } from './boardBackend';
|
|
5
|
+
import { readBoardState } from './boardState';
|
|
6
|
+
import { openOperatorBoard } from './boardInteraction';
|
|
7
|
+
import { refreshOperatorWorkspaceFiles } from './boardEvents';
|
|
8
|
+
/** Host API for the node workspace. Uses the same cloud-facing file API as the board. */
|
|
9
|
+
export function createOperatorWorkspace(taskId:string){
|
|
10
|
+
const workspace=createWorkspace({backend:getOperatorBoardBackend(),key:taskId,
|
|
11
|
+
view:()=>{const view=readBoardState(taskId);return view?.visible?cloneBoardView(view):undefined;},
|
|
12
|
+
context:()=>{const view=readBoardState(taskId);return view?cloneBoardView(view):null;},
|
|
13
|
+
open:path=>openOperatorBoard({kind:'file',taskId,path})});
|
|
14
|
+
return {...workspace,refresh:(options?:WorkspaceRefreshOptions)=>refreshOperatorWorkspaceFiles(taskId,options)};
|
|
15
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useEffect, useRef } from "react";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Re-check something that settles elsewhere (a browser-side login approval, a
|
|
7
|
+
* server job) — without an unbounded polling loop:
|
|
8
|
+
* · while `active`, call `check` every `intervalMs`, but only for `windowMs`
|
|
9
|
+
* after it became active (or after the last wake);
|
|
10
|
+
* · when the user comes back — the tab visible again, the window focused,
|
|
11
|
+
* back online — check once right away and open a fresh window. That is when
|
|
12
|
+
* a login approved in another tab is most likely to have settled.
|
|
13
|
+
* Nothing runs while the tab is hidden, and nothing at all once `active` is false.
|
|
14
|
+
*/
|
|
15
|
+
export function useBoundedPoll(active: boolean, check: () => void, intervalMs: number, windowMs: number): void {
|
|
16
|
+
const latest = useRef(check);
|
|
17
|
+
latest.current = check;
|
|
18
|
+
useEffect(() => {
|
|
19
|
+
if (!active) return;
|
|
20
|
+
let timer: ReturnType<typeof setInterval> | undefined;
|
|
21
|
+
let deadline = 0;
|
|
22
|
+
const stop = () => { if (timer) { clearInterval(timer); timer = undefined; } };
|
|
23
|
+
const start = () => {
|
|
24
|
+
deadline = Date.now() + windowMs;
|
|
25
|
+
if (timer) return;
|
|
26
|
+
timer = setInterval(() => {
|
|
27
|
+
if (Date.now() > deadline) { stop(); return; }
|
|
28
|
+
if (document.visibilityState !== "hidden") latest.current();
|
|
29
|
+
}, intervalMs);
|
|
30
|
+
};
|
|
31
|
+
const wake = () => {
|
|
32
|
+
if (document.visibilityState === "hidden") return;
|
|
33
|
+
latest.current();
|
|
34
|
+
start();
|
|
35
|
+
};
|
|
36
|
+
start();
|
|
37
|
+
window.addEventListener("focus", wake);
|
|
38
|
+
window.addEventListener("online", wake);
|
|
39
|
+
document.addEventListener("visibilitychange", wake);
|
|
40
|
+
return () => {
|
|
41
|
+
stop();
|
|
42
|
+
window.removeEventListener("focus", wake);
|
|
43
|
+
window.removeEventListener("online", wake);
|
|
44
|
+
document.removeEventListener("visibilitychange", wake);
|
|
45
|
+
};
|
|
46
|
+
}, [active, intervalMs, windowMs]);
|
|
47
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from "../branchFlow";
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import type { UploadPhase } from "../../core/documentUpload";
|
|
2
|
+
import { withoutBoardContext } from "../../board/context";
|
|
3
|
+
import { ORIGINALS_NOTE } from "../../core/blocks";
|
|
4
|
+
import { clientId } from "../../core/id";
|
|
5
|
+
import { splitAttachments } from "./format";
|
|
6
|
+
|
|
7
|
+
export type Quote = { id: string; author: string; text: string; sourceTaskId?: string };
|
|
8
|
+
export type Attachment = {
|
|
9
|
+
progress?: number;
|
|
10
|
+
phase?: UploadPhase; bytes?: number; size?: number;
|
|
11
|
+
key: string; kind: "image" | "file"; name: string; preview: string; path: string;
|
|
12
|
+
status: "uploading" | "ready" | "error"; error?: string;
|
|
13
|
+
};
|
|
14
|
+
export type ForkSource = { id: string; title: string };
|
|
15
|
+
export type ComposerDraft = { sources?: ForkSource[]; forkRequest?: { id: string; signature: string; payload?: string }; text: string; attachments: Attachment[]; quote: Quote | null };
|
|
16
|
+
const memory=new Map<string,{stamp:string;draft:ComposerDraft}>();
|
|
17
|
+
const stamp=(id:string)=>{try{return JSON.stringify([localStorage.getItem(key(id)),localStorage.getItem(`orch:draft:${id}`)]);}catch{return "blocked";}};
|
|
18
|
+
const key = (id: string) => `orch:composer:${id}`;
|
|
19
|
+
export const emptyDraft = (): ComposerDraft => ({ text: "", attachments: [], quote: null });
|
|
20
|
+
export function readDraft(id: string): ComposerDraft {
|
|
21
|
+
const cached=memory.get(id);if(cached?.stamp===stamp(id)) return cached.draft;
|
|
22
|
+
try {
|
|
23
|
+
const raw = localStorage.getItem(key(id));
|
|
24
|
+
if (raw) {
|
|
25
|
+
const d = JSON.parse(raw) as ComposerDraft;
|
|
26
|
+
if (typeof d.text === "string" && Array.isArray(d.attachments)) return { ...d, attachments: d.attachments.map((a) => a.status === "uploading" ? { ...a, preview: "", status: "error", error: "上传已中断,请重新选择文件。" } : a) };
|
|
27
|
+
}
|
|
28
|
+
return { ...emptyDraft(), text: localStorage.getItem(`orch:draft:${id}`) ?? "" };
|
|
29
|
+
} catch { return emptyDraft(); }
|
|
30
|
+
}
|
|
31
|
+
export const DRAFT_EVENT = "orch:composer-change";
|
|
32
|
+
const sendingTasks = new Set<string>();
|
|
33
|
+
export const isTaskSending = (id: string) => sendingTasks.has(id);
|
|
34
|
+
export function setTaskSending(id: string, sending: boolean) {
|
|
35
|
+
if (sending) sendingTasks.add(id); else sendingTasks.delete(id);
|
|
36
|
+
window.dispatchEvent(new CustomEvent(DRAFT_EVENT, { detail: { id, sending } }));
|
|
37
|
+
}
|
|
38
|
+
export function writeDraft(id: string, draft: ComposerDraft) {
|
|
39
|
+
try {
|
|
40
|
+
if (draft.text || draft.attachments.length || draft.quote || draft.sources?.length) localStorage.setItem(key(id), JSON.stringify(draft));
|
|
41
|
+
else localStorage.removeItem(key(id));
|
|
42
|
+
localStorage.removeItem(`orch:draft:${id}`);
|
|
43
|
+
} catch { /* Storage may be unavailable; the in-memory draft remains usable. */ }
|
|
44
|
+
memory.set(id,{stamp:stamp(id),draft});
|
|
45
|
+
window.dispatchEvent(new CustomEvent(DRAFT_EVENT, { detail: { id, draft } }));
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* 编辑排队消息:撤回成功后放回输入框。文字(含引用)和附件原件在前,输入框里还没发的草稿接在后面——两边都不丢。
|
|
49
|
+
* 去掉发送时会自动重新附带的部分:「看板正在看」的被动背景、带原件时自动加的那句提示。
|
|
50
|
+
*/
|
|
51
|
+
export function restoreQueuedDraft(taskId: string, content: string): void {
|
|
52
|
+
const { text, attachments } = splitAttachments(withoutBoardContext(content));
|
|
53
|
+
const body = text.split("\n").filter((line) => line.trim() !== ORIGINALS_NOTE).join("\n").replace(/\n{3,}/g, "\n\n").trim();
|
|
54
|
+
const current = readDraft(taskId);
|
|
55
|
+
writeDraft(taskId, {
|
|
56
|
+
...current,
|
|
57
|
+
text: [body, current.text].filter((value) => value.trim()).join("\n\n"),
|
|
58
|
+
attachments: [
|
|
59
|
+
...attachments.map((a): Attachment => ({ key: clientId(), kind: a.kind, name: a.name, path: a.path, preview: a.kind === "image" ? a.url : "", status: "ready" })),
|
|
60
|
+
...current.attachments,
|
|
61
|
+
],
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function quoteText(quote: Quote): string {
|
|
66
|
+
return `引用 ${quote.author}:\n${quote.text.split("\n").map((line) => `> ${line}`).join("\n")}`;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Remove only the content acknowledged by the server; preserve edits/quotes added during the request. */
|
|
70
|
+
export function acknowledgeDraft(current: ComposerDraft, sent: ComposerDraft): ComposerDraft {
|
|
71
|
+
const attachments=new Set(sent.attachments.map(a=>a.key));
|
|
72
|
+
const sources=new Set(sent.sources?.map(s=>s.id));
|
|
73
|
+
return {...current,text:current.text===sent.text?"":current.text,
|
|
74
|
+
attachments:current.attachments.filter(a=>!attachments.has(a.key)),
|
|
75
|
+
sources:current.sources?.filter(s=>!sources.has(s.id)),
|
|
76
|
+
quote:JSON.stringify(current.quote)===JSON.stringify(sent.quote)?null:current.quote,
|
|
77
|
+
forkRequest:undefined};
|
|
78
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
// 把智能体消息里指向本地文件的链接改写成能打开的地址。
|
|
2
|
+
//
|
|
3
|
+
// 智能体给的是它工作目录下的路径:[报告](report.md)、[报告](/Users/…/worktree/report.md)、
|
|
4
|
+
// file:///…、带行号的 src/a.ts:12。原样渲染时浏览器把它当成网站根目录下的地址,
|
|
5
|
+
// 打不开。这里改写到 /api/tasks/<id>/files/…(服务端见 lib/taskFiles.ts),
|
|
6
|
+
// Markdown 则改到 /view 预览页渲染。
|
|
7
|
+
import { createContext } from "react";
|
|
8
|
+
|
|
9
|
+
/** 当前渲染的 Markdown 属于哪个任务、相对哪个目录(预览页里是 md 文件所在目录)。 */
|
|
10
|
+
export type FileLinkScope = { taskId: string; baseDir?: string } | null;
|
|
11
|
+
export const FileLinkContext = createContext<FileLinkScope>(null);
|
|
12
|
+
|
|
13
|
+
const SCHEME = /^[a-z][a-z0-9+.-]*:/i;
|
|
14
|
+
const WIN_ABS = /^[a-z]:[\\/]/i;
|
|
15
|
+
|
|
16
|
+
function normalize(p: string): string {
|
|
17
|
+
const abs = p.startsWith("/");
|
|
18
|
+
const out: string[] = [];
|
|
19
|
+
for (const seg of p.split("/")) {
|
|
20
|
+
if (!seg || seg === ".") continue;
|
|
21
|
+
if (seg === ".." && out.length && out[out.length - 1] !== "..") out.pop();
|
|
22
|
+
else if (seg === ".." && abs) continue;
|
|
23
|
+
else out.push(seg);
|
|
24
|
+
}
|
|
25
|
+
return (abs ? "/" : "") + out.join("/");
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function isMarkdownPath(p: string): boolean {
|
|
29
|
+
return /\.(md|markdown)$/i.test(p);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** 文件在服务端的地址:相对路径用路径式(HTML 的相对资源能解析),绝对路径走 ?path=。 */
|
|
33
|
+
export function taskFileApiUrl(taskId: string, p: string): string {
|
|
34
|
+
const id = encodeURIComponent(taskId);
|
|
35
|
+
if (p.startsWith("/") || p.startsWith("~") || WIN_ABS.test(p)) return `/api/tasks/${id}/files?path=${encodeURIComponent(p)}`;
|
|
36
|
+
return `/api/tasks/${id}/files/${p.split("/").filter(Boolean).map(encodeURIComponent).join("/")}`;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function markdownViewUrl(taskId: string, p: string): string {
|
|
40
|
+
return `/view?task=${encodeURIComponent(taskId)}&path=${encodeURIComponent(p)}`;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* 链接指向本地文件时,返回它的路径(相对任务工作目录,或绝对路径);
|
|
45
|
+
* 网址、锚点、应用自己的地址等返回 null(保持原样)。
|
|
46
|
+
*/
|
|
47
|
+
export function localFilePath(href: string | undefined | null, baseDir = ""): string | null {
|
|
48
|
+
let h = String(href ?? "").trim();
|
|
49
|
+
if (!h || h.startsWith("#") || h.startsWith("//") || h.startsWith("/api/") || h.startsWith("/view?")) return null;
|
|
50
|
+
if (/^file:/i.test(h)) h = h.replace(/^file:(\/\/localhost)?(\/\/)?/i, "");
|
|
51
|
+
else if (SCHEME.test(h) && !WIN_ABS.test(h)) return null;
|
|
52
|
+
// 去掉查询和锚点(#L12 之类),再解码(Markdown 解析器会把中文转成 %XX)。
|
|
53
|
+
h = h.replace(/[?#].*$/, "");
|
|
54
|
+
try {
|
|
55
|
+
h = decodeURI(h);
|
|
56
|
+
} catch {
|
|
57
|
+
// 保持原样
|
|
58
|
+
}
|
|
59
|
+
h = h.replace(/\\/g, "/");
|
|
60
|
+
// 编辑器风格的行号:src/a.ts:12 或 :12:5
|
|
61
|
+
h = h.replace(/:(\d+)(:\d+)?$/, "");
|
|
62
|
+
if (!h) return null;
|
|
63
|
+
if (h === "~" || h.startsWith("~/")) return h;
|
|
64
|
+
if (h.startsWith("/") || WIN_ABS.test(h)) return WIN_ABS.test(h) ? h : normalize(h);
|
|
65
|
+
return normalize(baseDir ? `${baseDir.replace(/\/+$/, "")}/${h}` : h);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** 链接改写的最终结果:Markdown → 预览页;其他文件 → 文件接口;不是本地文件 → null。 */
|
|
69
|
+
export function rewriteFileHref(scope: FileLinkScope, href: string | undefined | null): string | null {
|
|
70
|
+
if (!scope) return null;
|
|
71
|
+
const p = localFilePath(href, scope.baseDir);
|
|
72
|
+
if (!p) return null;
|
|
73
|
+
return isMarkdownPath(p) ? markdownViewUrl(scope.taskId, p) : taskFileApiUrl(scope.taskId, p);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** 图片等内嵌资源:总是直接取文件。 */
|
|
77
|
+
export function rewriteFileSrc(scope: FileLinkScope, src: string | undefined | null): string | null {
|
|
78
|
+
if (!scope) return null;
|
|
79
|
+
const p = localFilePath(src, scope.baseDir);
|
|
80
|
+
return p ? taskFileApiUrl(scope.taskId, p) : null;
|
|
81
|
+
}
|
|
@@ -0,0 +1,292 @@
|
|
|
1
|
+
// Pure formatting + derivation helpers shared across the orchestrator modules.
|
|
2
|
+
import type { AskQuestion, AskAnswers } from "./protocol/types";
|
|
3
|
+
import type { Msg, TaskRow, AgentCapabilities, AgentInfo } from "./types";
|
|
4
|
+
import type { InternalUsageEstimate } from "./types";
|
|
5
|
+
|
|
6
|
+
// Compact token count: 1234 → "1.2k", 1_200_000 → "1.2M".
|
|
7
|
+
export function fmtTokens(n: number): string {
|
|
8
|
+
if (n >= 1e6) return `${(n / 1e6).toFixed(n >= 1e7 ? 0 : 1)}M`;
|
|
9
|
+
if (n >= 1e3) return `${(n / 1e3).toFixed(n >= 1e4 ? 0 : 1)}k`;
|
|
10
|
+
return String(n);
|
|
11
|
+
}
|
|
12
|
+
// Human byte size: 1536 → "1.5 KB", 5_242_880 → "5.0 MB". Base-1024.
|
|
13
|
+
export function fmtBytes(n: number): string {
|
|
14
|
+
if (n <= 0) return "0 B";
|
|
15
|
+
const units = ["B", "KB", "MB", "GB", "TB"];
|
|
16
|
+
const i = Math.min(units.length - 1, Math.floor(Math.log(n) / Math.log(1024)));
|
|
17
|
+
const v = n / 1024 ** i;
|
|
18
|
+
return `${i === 0 ? v : v.toFixed(v >= 10 || i === 0 ? 0 : 1)} ${units[i]}`;
|
|
19
|
+
}
|
|
20
|
+
// Dollar cost: sub-cent shows "<$0.01"; otherwise 2–3 sig digits after the point.
|
|
21
|
+
export function fmtCost(n: number): string {
|
|
22
|
+
if (n <= 0) return "$0.00";
|
|
23
|
+
if (n < 0.01) return "<$0.01";
|
|
24
|
+
return `$${n < 1 ? n.toFixed(3) : n.toFixed(2)}`;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function fmtJobCost(e: InternalUsageEstimate): string {
|
|
28
|
+
const cost = e.cost_usd <= 0 ? "$0.00" : e.cost_usd < 0.01 ? "<$0.01" : `$${e.cost_usd.toFixed(2)}`;
|
|
29
|
+
return `约 ${fmtTokens(e.tokens)} tokens(约 ${cost})`;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// ---------- the usage chip (tokens + cost, honestly) ----------
|
|
33
|
+
|
|
34
|
+
// A task's cumulative tokens split by what they actually represent. The raw
|
|
35
|
+
// `total_tokens` sums all four buckets, and in real sessions ~90%+ of it is
|
|
36
|
+
// prompt-cache READS — the same context re-sent on every turn and billed at ~10%
|
|
37
|
+
// of the input rate. Leading with that reads as "this task burned 3.8M tokens"
|
|
38
|
+
// when the model only ever processed ~250k of new material, which is what scares
|
|
39
|
+
// people off. So the chip leads with `fresh` (tokens seen for the first time:
|
|
40
|
+
// in/out plus cache WRITES, which are billed above input rate) and carries cache
|
|
41
|
+
// reads as secondary detail. Defensive ?? 0s: a task row can predate the fields.
|
|
42
|
+
export interface UsageSplit {
|
|
43
|
+
total: number; // every bucket summed — what task.total_tokens holds
|
|
44
|
+
fresh: number; // in/out + cache writes: material the model processed anew
|
|
45
|
+
inOut: number; // prompt + completion tokens, uncached
|
|
46
|
+
cacheWrite: number; // context written into the cache (billed ~1.25× input)
|
|
47
|
+
cacheRead: number; // context re-read from the cache (billed ~0.1× input)
|
|
48
|
+
}
|
|
49
|
+
export function usageSplit(t: Pick<TaskRow, "total_tokens" | "cache_read_tokens" | "cache_creation_tokens">): UsageSplit {
|
|
50
|
+
const total = t.total_tokens ?? 0;
|
|
51
|
+
const cacheRead = t.cache_read_tokens ?? 0;
|
|
52
|
+
const cacheWrite = t.cache_creation_tokens ?? 0;
|
|
53
|
+
return { total, cacheRead, cacheWrite, inOut: Math.max(0, total - cacheRead - cacheWrite), fresh: Math.max(0, total - cacheRead) };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* How to present an agent's dollar figure. Two independent questions:
|
|
58
|
+
*
|
|
59
|
+
* - Is the number a MEASUREMENT or an estimate? `costIsEstimated` answers that
|
|
60
|
+
* (Codex reports tokens only, so its figure is tokens × published prices).
|
|
61
|
+
* - Is the number MONEY THE USER SPENDS? Only under api-key auth. On a Max/Pro
|
|
62
|
+
* (or ChatGPT) subscription the marginal cost of a turn is $0 — the SDK's
|
|
63
|
+
* `total_cost_usd` is what the same tokens would have cost through the API,
|
|
64
|
+
* and what's actually consumed is plan quota. Showing a bare "$4.20" there
|
|
65
|
+
* reads as a bill for something that was included.
|
|
66
|
+
*
|
|
67
|
+
* Either one makes the figure approximate-in-meaning, so both get an `~` plus a
|
|
68
|
+
* tooltip clause saying which it is. An unknown account (bundle still loading,
|
|
69
|
+
* agent not connected) keeps the plain billed presentation — we won't claim a
|
|
70
|
+
* turn was covered by a plan we can't see.
|
|
71
|
+
*/
|
|
72
|
+
export interface CostDisplay {
|
|
73
|
+
show: boolean; // render a dollar figure at all
|
|
74
|
+
approx: boolean; // prefix it with ~
|
|
75
|
+
note: string; // tooltip clause explaining what the figure means ("" = a plain billed charge)
|
|
76
|
+
}
|
|
77
|
+
export function costDisplay(agent: AgentInfo | undefined): CostDisplay {
|
|
78
|
+
const caps = agent?.capabilities;
|
|
79
|
+
const estimated = caps?.costIsEstimated === true;
|
|
80
|
+
const show = caps?.reportsCostUsd !== false || estimated;
|
|
81
|
+
const subscription = agent?.account?.method === "subscription";
|
|
82
|
+
const bedrock = agent?.account?.method === "bedrock";
|
|
83
|
+
const plan = agent?.account?.plan;
|
|
84
|
+
// "Max"/"Pro"/"ChatGPT Plus" → "your Max plan"; unknown/"API" → "your plan".
|
|
85
|
+
const planName = plan && !/^api$/i.test(plan) ? `你的 ${plan} 订阅` : "你的订阅";
|
|
86
|
+
const source = estimated ? "按 token 数 × 官方 API 价格估算" : "等同 API 价格的折算值";
|
|
87
|
+
const note = bedrock
|
|
88
|
+
? "由 Claude Code 根据 token 用量与公开价格估算;请以你的 AWS 账单为准"
|
|
89
|
+
: subscription
|
|
90
|
+
? `${source}:本次使用 ${planName} 登录运行,消耗的是套餐额度,并非实际账单`
|
|
91
|
+
: estimated
|
|
92
|
+
? source
|
|
93
|
+
: "";
|
|
94
|
+
return { show, approx: subscription || bedrock || estimated, note };
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// The usage chip's tooltip: the full breakdown the compact chip can't fit, one
|
|
98
|
+
// fact per line. Exact counts here (the chip rounds) — this is the view someone
|
|
99
|
+
// opens precisely because the rounded number surprised them.
|
|
100
|
+
export function usageTooltip(split: UsageSplit, costUsd: number, cost: CostDisplay): string {
|
|
101
|
+
const n = (v: number) => v.toLocaleString();
|
|
102
|
+
const lines = [
|
|
103
|
+
`本任务新增 ${n(split.fresh)} tokens:输入/输出 ${n(split.inOut)} · 写入缓存 ${n(split.cacheWrite)}`,
|
|
104
|
+
];
|
|
105
|
+
if (split.cacheRead > 0) {
|
|
106
|
+
lines.push(`缓存读取 ${n(split.cacheRead)}(每轮重复读取的上下文,按输入价格的约 10% 计费)`);
|
|
107
|
+
lines.push(`合计 ${n(split.total)} tokens`);
|
|
108
|
+
}
|
|
109
|
+
if (cost.show && costUsd > 0) {
|
|
110
|
+
lines.push(`${cost.approx ? "约 " : ""}${fmtCost(costUsd)}${cost.note ? `(${cost.note})` : "(实际计费)"}`);
|
|
111
|
+
}
|
|
112
|
+
return lines.join("\n");
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// Context window: the input-side tokens of the latest turn ≈ how full that
|
|
116
|
+
// window currently is. The size comes from the agent's capability descriptor
|
|
117
|
+
// (capabilities.models[].contextWindow) — Codex windows differ from Claude's, so
|
|
118
|
+
// it can't be a static Claude-only table. `caps.models` is looked up by the
|
|
119
|
+
// task's configured model value; unknown/Default falls back to the widest model
|
|
120
|
+
// the agent offers, then a conservative constant.
|
|
121
|
+
export const DEFAULT_CONTEXT_WINDOW = 200_000;
|
|
122
|
+
export function contextWindowOf(model: string | null | undefined, caps?: AgentCapabilities): number {
|
|
123
|
+
const models = caps?.models ?? [];
|
|
124
|
+
if (model) {
|
|
125
|
+
const hit = models.find((m) => m.value === model);
|
|
126
|
+
if (hit) return hit.contextWindow;
|
|
127
|
+
const id = model.toLowerCase();
|
|
128
|
+
if (id.includes("[1m]") || id.includes("claude-sonnet-5") || id.includes("claude-fable-5")) return 1_000_000;
|
|
129
|
+
return DEFAULT_CONTEXT_WINDOW;
|
|
130
|
+
}
|
|
131
|
+
// Default (null) model → the driver picks its own; approximate with the widest
|
|
132
|
+
// window it offers so the gauge doesn't over-report fullness.
|
|
133
|
+
const widest = models.reduce((mx, m) => Math.max(mx, m.contextWindow), 0);
|
|
134
|
+
return widest || DEFAULT_CONTEXT_WINDOW;
|
|
135
|
+
}
|
|
136
|
+
export function contextPct(tokens: number, model: string | null | undefined, caps?: AgentCapabilities): number {
|
|
137
|
+
return Math.round((tokens / contextWindowOf(model, caps)) * 1000) / 10;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// Friendly name for a resolved model id — the badge that answers "which model
|
|
141
|
+
// did this turn actually run on?". The VERSION is the point: family aliases move
|
|
142
|
+
// (today "opus" resolves to claude-opus-5, last month claude-opus-4-8), so a bare
|
|
143
|
+
// "Opus" badge tells you nothing. Parse family + version out of the id first
|
|
144
|
+
// ("claude-opus-5" -> "Opus 5", "claude-opus-4-8-20251101" -> "Opus 4.8") and
|
|
145
|
+
// keep the `[1m]` marker, since the 1M variant is a distinct run mode.
|
|
146
|
+
// Non-Claude ids (Codex's "gpt-5.1-codex-max") carry no such version shape —
|
|
147
|
+
// those fall through to the agent's capability labels, matched longest-first so
|
|
148
|
+
// a shorter value can't shadow a more specific one. Raw id is the last resort.
|
|
149
|
+
export function modelLabel(id: string | null, caps?: AgentCapabilities): string {
|
|
150
|
+
if (!id) return "";
|
|
151
|
+
const s = id.toLowerCase();
|
|
152
|
+
const long = s.includes("[1m]") ? " (1M)" : "";
|
|
153
|
+
const fam = ["fable", "opus", "sonnet", "haiku"].find((f) => s.includes(f));
|
|
154
|
+
if (fam) {
|
|
155
|
+
const cap = fam[0].toUpperCase() + fam.slice(1);
|
|
156
|
+
const v = s.match(new RegExp(`${fam}-(\\d+)(?:-(\\d+))?`));
|
|
157
|
+
// Deliberately NOT the capability label here: an id we can't read a version
|
|
158
|
+
// out of shouldn't be badged with a version we're only guessing at.
|
|
159
|
+
return v ? `${cap} ${v[1]}${v[2] ? `.${v[2]}` : ""}${long}` : `${cap}${long}`;
|
|
160
|
+
}
|
|
161
|
+
const hit = (caps?.models ?? [])
|
|
162
|
+
.filter((o) => s.includes(o.value.toLowerCase()))
|
|
163
|
+
.sort((a, b) => b.value.length - a.value.length)[0];
|
|
164
|
+
return hit ? hit.label : id;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// Phrase AskUserQuestion answers as a reply, for the reload fallback where the
|
|
168
|
+
// turn is no longer parked and we resume the session with a normal message.
|
|
169
|
+
export function formatAnswersText(questions: AskQuestion[], answers: AskAnswers): string {
|
|
170
|
+
const lines = questions.map((q, i) => {
|
|
171
|
+
const picked = (answers[i] ?? []).filter((s) => s && s.trim());
|
|
172
|
+
return `- ${q.header || q.question}:${picked.length ? picked.join("、") : "(未选择)"}`;
|
|
173
|
+
});
|
|
174
|
+
return `回答你的问题:\n${lines.join("\n")}`;
|
|
175
|
+
}
|
|
176
|
+
// Absolute wall-clock stamp for a transcript message ("3:42 PM", locale-aware).
|
|
177
|
+
// Messages from an earlier day prefix the date, so a task resumed days later
|
|
178
|
+
// still reads right. Absolute on purpose: MessageView is memoized, so a
|
|
179
|
+
// relative "2m ago" would freeze at whatever it said when the row rendered.
|
|
180
|
+
export function clockTime(ts: number): string {
|
|
181
|
+
const d = new Date(ts);
|
|
182
|
+
const time = d.toLocaleTimeString([], { hour: "numeric", minute: "2-digit" });
|
|
183
|
+
if (d.toDateString() === new Date().toDateString()) return time;
|
|
184
|
+
return `${d.toLocaleDateString([], { month: "short", day: "numeric" })}, ${time}`;
|
|
185
|
+
}
|
|
186
|
+
export function relTime(ts: number): string {
|
|
187
|
+
const d = Date.now() - ts;
|
|
188
|
+
const m = Math.floor(d / 60000);
|
|
189
|
+
if (m < 1) return "刚刚";
|
|
190
|
+
if (m < 60) return `${m} 分钟前`;
|
|
191
|
+
const h = Math.floor(m / 60);
|
|
192
|
+
if (h < 24) return `${h} 小时前`;
|
|
193
|
+
return `${Math.floor(h / 24)} 天前`;
|
|
194
|
+
}
|
|
195
|
+
// How long a task has been waiting on the user, spelled out for the "need you"
|
|
196
|
+
// dropdown ("waiting for 3 hours"). Coarser and more verbose than relTime — this
|
|
197
|
+
// is the only subline a row gets, so it reads as prose rather than a chip.
|
|
198
|
+
export function waitedFor(since: number): string {
|
|
199
|
+
const s = Math.max(0, Math.round((Date.now() - since) / 1000));
|
|
200
|
+
if (s < 45) return "几秒";
|
|
201
|
+
const m = Math.round(s / 60);
|
|
202
|
+
if (m < 1) return "1 分钟";
|
|
203
|
+
if (m < 60) return `${m} 分钟`;
|
|
204
|
+
const h = Math.round(m / 60);
|
|
205
|
+
if (h < 24) return `${h} 小时`;
|
|
206
|
+
const d = Math.round(h / 24);
|
|
207
|
+
return `${d} 天`;
|
|
208
|
+
}
|
|
209
|
+
export function duration(start: number, end: number | null): string {
|
|
210
|
+
if (!end) return "进行中";
|
|
211
|
+
const s = Math.max(0, Math.round((end - start) / 1000));
|
|
212
|
+
if (s < 60) return `${s}s`;
|
|
213
|
+
const m = Math.floor(s / 60);
|
|
214
|
+
if (m < 60) return `${m}m ${s % 60}s`;
|
|
215
|
+
return `${Math.floor(m / 60)}h ${m % 60}m`;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// A task is "waiting on you" when its awaiting_input flag is set — Claude either
|
|
219
|
+
// ended its turn mid-task or is parked on an AskUserQuestion. The flag is the
|
|
220
|
+
// single source of truth (cleared the instant the next turn starts / a question
|
|
221
|
+
// is answered), so this holds even while the turn is technically still live and
|
|
222
|
+
// parked on the question — that's exactly the case the task list must surface.
|
|
223
|
+
export const isAwaiting = (t: TaskRow) =>
|
|
224
|
+
t.status === "in_progress" && !!t.awaiting_input;
|
|
225
|
+
|
|
226
|
+
// The titles of a task's unfinished blockers (dependencies not yet 'done'). A
|
|
227
|
+
// task with any of these is "blocked" and can't be started until they complete.
|
|
228
|
+
// A cancelled dependency doesn't block — it's terminal and will never finish,
|
|
229
|
+
// so waiting on it would deadlock the dependent task forever.
|
|
230
|
+
export const blockerTitles = (t: TaskRow, byId: Map<string, TaskRow>): string[] =>
|
|
231
|
+
(t.depends_on ?? [])
|
|
232
|
+
.map((id) => byId.get(id))
|
|
233
|
+
.filter((b): b is TaskRow => !!b && b.status !== "done" && b.status !== "cancelled")
|
|
234
|
+
.map((b) => b.title);
|
|
235
|
+
|
|
236
|
+
// add/del/ctx class for a diff line's sign — shared by the peek and full views.
|
|
237
|
+
export const diffCls = (sign: "+" | "-" | " ") => (sign === "+" ? "add" : sign === "-" ? "del" : "ctx");
|
|
238
|
+
|
|
239
|
+
// group flat messages into per-generation sessions, pulling out the /clear summaries
|
|
240
|
+
export function buildSessions(messages: Msg[]) {
|
|
241
|
+
const summaryByGen: Record<number, string> = {};
|
|
242
|
+
for (const m of messages) if (m.role === "session_break") summaryByGen[m.generation] = m.content;
|
|
243
|
+
// Queued follow-ups are excluded here — they haven't run yet, so SessionView
|
|
244
|
+
// renders them in a pinned block below the live "thinking" indicator instead
|
|
245
|
+
// of interleaved with the committed transcript.
|
|
246
|
+
const committed = messages.filter((m) => m.role !== "queued");
|
|
247
|
+
const gens = Array.from(new Set(committed.filter((m) => m.role !== "session_break").map((m) => m.generation))).sort((a, b) => a - b);
|
|
248
|
+
return gens.map((n) => ({
|
|
249
|
+
n,
|
|
250
|
+
summaryBefore: summaryByGen[n - 1] ?? null,
|
|
251
|
+
messages: committed.filter((m) => m.generation === n && m.role !== "session_break"),
|
|
252
|
+
}));
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
// ---------- chat attachments (images + large text pastes) ----------
|
|
256
|
+
// An upload travels inside the message text as one marker line per file:
|
|
257
|
+
// "[Attached image: /abs/path.png]" for images, "[Attached file: /abs/path.txt]"
|
|
258
|
+
// for a big text paste diverted to a file (see PASTE_ATTACH_THRESHOLD). The
|
|
259
|
+
// same string serves both sides — Claude Code opens the absolute path with its
|
|
260
|
+
// Read tool (rendering images natively, reading text files as text), and the
|
|
261
|
+
// transcript strips the marker back out to render an inline thumbnail (image)
|
|
262
|
+
// or a file chip (text). The serving URL is derived from the path's
|
|
263
|
+
// uploads/<task>/<file> tail, so no extra columns or event fields are needed.
|
|
264
|
+
export const attachmentMarker = (absPath: string) => `[Attached image: ${absPath}]`;
|
|
265
|
+
export const fileAttachmentMarker = (absPath: string) => `[Attached file: ${absPath}]`;
|
|
266
|
+
const ATTACHMENT_RE = /^\[Attached (image|file): (.+)\]$/;
|
|
267
|
+
|
|
268
|
+
export interface MsgAttachment { path: string; url: string; kind: "image" | "file"; name: string }
|
|
269
|
+
|
|
270
|
+
// Split a user message into displayable text + attachment chips. Marker lines
|
|
271
|
+
// whose path doesn't end in uploads/<task>/<file> (hand-typed lookalikes) stay
|
|
272
|
+
// in the text untouched.
|
|
273
|
+
export function splitAttachments(content: string): { text: string; attachments: MsgAttachment[] } {
|
|
274
|
+
if (!content.includes("[Attached image: ") && !content.includes("[Attached file: ")) {
|
|
275
|
+
return { text: content, attachments: [] };
|
|
276
|
+
}
|
|
277
|
+
const attachments: MsgAttachment[] = [];
|
|
278
|
+
const kept: string[] = [];
|
|
279
|
+
for (const line of content.split("\n")) {
|
|
280
|
+
const m = ATTACHMENT_RE.exec(line.trim());
|
|
281
|
+
const parts = m ? m[2].split(/[\\/]/).filter(Boolean) : [];
|
|
282
|
+
const upload = parts.length >= 3 && parts[parts.length - 3] === "uploads";
|
|
283
|
+
const managed = parts.length >= 4 && parts[parts.length - 4] === ".workspace" && parts[parts.length - 3] === "attachments";
|
|
284
|
+
if (m && (upload || managed) && parts.slice(-2).every(part => part !== "." && part !== "..")) {
|
|
285
|
+
const [taskId, file] = parts.slice(-2);
|
|
286
|
+
attachments.push({ path: m[2], url: `/api/tasks/${encodeURIComponent(taskId)}/uploads/${encodeURIComponent(file)}`, kind: m[1] === "image" ? "image" : "file", name: file });
|
|
287
|
+
} else {
|
|
288
|
+
kept.push(line);
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
return { text: kept.join("\n").trim(), attachments };
|
|
292
|
+
}
|