@k2b/cloud 0.30.0 → 0.31.0
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/package.json +3 -2
- package/src/_internal/app-presentation.ts +22 -1
- package/src/_internal/capabilities.ts +2 -2
- package/src/_internal/define-app.ts +14 -4
- package/src/_internal/registry.ts +50 -6
- package/src/_internal/widget-declarations.ts +53 -0
- package/src/ai/approval-routes.ts +40 -4
- package/src/ai/approvals.ts +68 -10
- package/src/ai/chat/blocks.tsx +53 -32
- package/src/ai/chat/live-turn.browser-harness.tsx +4 -1
- package/src/ai/chat/message-actions.tsx +3 -1
- package/src/ai/chat/messages.ts +18 -0
- package/src/ai/chat/tool-groups.ts +66 -12
- package/src/ai/chat/tool-names.ts +4 -2
- package/src/ai/chat/turn-layout.ts +8 -0
- package/src/ai/chat/turn-view.tsx +94 -25
- package/src/ai/chat/web-tools.tsx +95 -1
- package/src/ai/client/controller.ts +6 -3
- package/src/ai/code-runtime-tools.ts +50 -17
- package/src/ai/code-source-contracts.ts +25 -21
- package/src/ai/code-source-tools.ts +3 -1
- package/src/ai/executor.ts +170 -14
- package/src/ai/fetch-file-tool.ts +41 -4
- package/src/ai/firecrawl-tools.ts +87 -4
- package/src/ai/index.ts +8 -0
- package/src/ai/migrate.ts +113 -2
- package/src/ai/protocol.ts +22 -1
- package/src/ai/routes.ts +11 -0
- package/src/ai/runtime.ts +46 -7
- package/src/ai/store.ts +66 -1
- package/src/ai/tool-audit.ts +11 -1
- package/src/ai/tools.ts +91 -3
- package/src/ai/types.ts +39 -0
- package/src/ai/web-provenance.ts +103 -0
- package/src/api/capabilities.ts +3 -2
- package/src/api/widgets.ts +36 -5
- package/src/browser/CloudResourceSearch.tsx +1 -1
- package/src/browser/command-shortcuts.ts +21 -6
- package/src/browser/spotlight-position.ts +4 -1
- package/src/browser/widgets.ts +7 -5
- package/src/capabilities/executions.ts +3 -0
- package/src/cli/commands.ts +20 -0
- package/src/cli/index.ts +16 -0
- package/src/cli/plugin.ts +30 -0
- package/src/contracts/app.ts +34 -2
- package/src/contracts/registry.ts +9 -1
- package/src/contracts/widgets.ts +47 -2
- package/src/desktop/solid.tsx +6 -7
- package/src/server/index.ts +1 -0
- package/src/server/widget-request.ts +27 -0
- package/src/services/approvals/index.ts +225 -0
- package/src/services/approvals/migrate.ts +33 -0
- package/src/shared/app-presentation.ts +11 -0
- package/src/shared/email-html.ts +43 -8
- package/src/ssr/AdminLayout.tsx +4 -7
- package/src/ssr/AppLaunchpad.island.tsx +1 -1
- package/src/ssr/Layout.tsx +38 -14
- package/src/ssr/LayoutFooter.tsx +1 -1
- package/src/ssr/LayoutHeader.tsx +17 -20
- package/src/ssr/LayoutHelp.tsx +1 -1
- package/src/ssr/LayoutRail.tsx +1 -2
- package/src/ssr/PageTrail.tsx +23 -0
- package/src/ssr/PageTrailRow.island.tsx +20 -0
- package/src/ssr/PageTrailRuntime.island.tsx +26 -0
- package/src/ssr/ProfilePreferences.island.tsx +7 -3
- package/src/ssr/islands/index.ts +1 -0
- package/src/ssr/layout-runtime.ts +60 -0
- package/src/ssr/mobile-menu-history.ts +1 -1
- package/src/ssr/page-trail-runtime.ts +313 -0
- package/src/ssr/page-trail.browser-harness.tsx +216 -0
- package/src/ssr/page-trail.ts +59 -0
- package/src/ssr/platform-messages.ts +4 -0
- package/src/styles/effects.css +21 -4
- package/src/styles/global.css +35 -2
- package/src/styles/utilities-layout.css +24 -1
- package/src/styles/utilities-navigation.css +6 -18
package/src/ai/chat/blocks.tsx
CHANGED
|
@@ -34,7 +34,7 @@ import { AssistantMarkdownBlock } from "./primitives";
|
|
|
34
34
|
import { AiToolActivity } from "./tool-disclosure";
|
|
35
35
|
import { isFailedTool } from "./tool-groups";
|
|
36
36
|
import { CloudCardBlock, CloudSurveyBlock, CloudSurveyResultBlock, CloudTextEditorBlock, CloudTextEditorResultBlock } from "./visual-tools";
|
|
37
|
-
import { FetchFileToolBlock, WebExtractToolBlock, WebSearchToolBlock } from "./web-tools";
|
|
37
|
+
import { FetchFileToolBlock, WebExtractToolBlock, WebSearchToolBlock, WebsiteReceipts, websiteReceipts } from "./web-tools";
|
|
38
38
|
|
|
39
39
|
type ToolBlock = Extract<AiTurnBlock, { kind: "tool" }>;
|
|
40
40
|
type ReviewDetail = NonNullable<CapabilityActionReview["details"]>[number];
|
|
@@ -205,7 +205,7 @@ export function ApprovalBlockView(props: { turnId: string; block: ToolBlock; onD
|
|
|
205
205
|
const [detailsOpen, setDetailsOpen] = createSignal(false);
|
|
206
206
|
// The call this card answered: a later approval for the same block may already have arrived when the server accepts.
|
|
207
207
|
let decision: AiApprovalDecision | null = null;
|
|
208
|
-
const approval = mutation.create<void, { approved: boolean; remember?: "always" }>({
|
|
208
|
+
const approval = mutation.create<void, { approved: boolean; remember?: "chat" | "always" }>({
|
|
209
209
|
mutation: async (input) => {
|
|
210
210
|
if (!actions.onApproval) throw new Error("Approval is unavailable.");
|
|
211
211
|
await actions.onApproval(request(), input);
|
|
@@ -215,7 +215,7 @@ export function ApprovalBlockView(props: { turnId: string; block: ToolBlock; onD
|
|
|
215
215
|
if (decision) props.onDecided?.(decision);
|
|
216
216
|
},
|
|
217
217
|
});
|
|
218
|
-
const submit = (input: { approved: boolean; remember?: "always" }) => {
|
|
218
|
+
const submit = (input: { approved: boolean; remember?: "chat" | "always" }) => {
|
|
219
219
|
if (actionDisabled() || approval.loading()) return;
|
|
220
220
|
decision = { callId: props.block.callId, approved: input.approved };
|
|
221
221
|
void approval.mutate(input);
|
|
@@ -411,6 +411,16 @@ export function ApprovalBlockView(props: { turnId: string; block: ToolBlock; onD
|
|
|
411
411
|
icon: detailsOpen() ? "ti ti-eye-off" : "ti ti-eye",
|
|
412
412
|
action: () => setDetailsOpen((open) => !open),
|
|
413
413
|
},
|
|
414
|
+
// The narrower chat reach comes first: it ends with this chat.
|
|
415
|
+
...(props.block.approval?.allowChat
|
|
416
|
+
? [
|
|
417
|
+
{
|
|
418
|
+
label: props.block.approval.website ? t().allowWebsiteForChat : t().chatApprove,
|
|
419
|
+
icon: props.block.approval.website ? "ti ti-world-check" : "ti ti-message-check",
|
|
420
|
+
action: () => submit({ approved: true, remember: "chat" }),
|
|
421
|
+
},
|
|
422
|
+
]
|
|
423
|
+
: []),
|
|
414
424
|
...(props.block.approval?.allowAlways
|
|
415
425
|
? [
|
|
416
426
|
{
|
|
@@ -725,6 +735,9 @@ function ToolBlockView(props: { turnId: string; block: ToolBlock; active?: boole
|
|
|
725
735
|
<Chat.Activity label={displayToolName(props.block.name, locale())} icon={aiToolIcon(props.block.name)} busy />
|
|
726
736
|
</Match>
|
|
727
737
|
</Switch>
|
|
738
|
+
<Show when={websiteReceipts(props.block).length > 0}>
|
|
739
|
+
<WebsiteReceipts block={props.block} />
|
|
740
|
+
</Show>
|
|
728
741
|
<Show when={hasCapabilityTable(props.block)}>
|
|
729
742
|
<CapabilityTablePreview
|
|
730
743
|
result={props.block.result}
|
|
@@ -853,35 +866,43 @@ export function CompactToolRow(props: { block: ToolBlock; busy?: boolean }) {
|
|
|
853
866
|
return aiToolIcon(props.block.name, props.block.presentation?.appIcon);
|
|
854
867
|
};
|
|
855
868
|
return (
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
<div
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
869
|
+
<>
|
|
870
|
+
<AiToolActivity
|
|
871
|
+
blockId={props.block.id}
|
|
872
|
+
label={sentence() ?? props.block.presentation?.title ?? displayToolName(props.block.name, locale())}
|
|
873
|
+
description={[
|
|
874
|
+
props.block.status === "running" ? (props.block.progress ?? "") : "",
|
|
875
|
+
typeof detail() === "string" ? String(detail()) : "",
|
|
876
|
+
state(),
|
|
877
|
+
outcome(),
|
|
878
|
+
]
|
|
879
|
+
.filter(Boolean)
|
|
880
|
+
.join(" · ")}
|
|
881
|
+
icon={icon()}
|
|
882
|
+
busy={props.busy && props.block.status === "running"}
|
|
883
|
+
bodyInset={false}
|
|
884
|
+
renderBody={() => (
|
|
885
|
+
<div class="flex min-w-0 flex-col gap-2">
|
|
886
|
+
<div
|
|
887
|
+
class="max-h-72 overflow-auto overscroll-contain rounded-md bg-zinc-100 p-3 dark:bg-zinc-950"
|
|
888
|
+
tabIndex={0}
|
|
889
|
+
role="region"
|
|
890
|
+
aria-label={t().toolInputOutput}
|
|
891
|
+
>
|
|
892
|
+
<ToolDetail title={t().input} toolName={props.block.name} value={props.block.args} />
|
|
893
|
+
<ToolDetail title={t().output} toolName={props.block.name} value={props.block.result} />
|
|
894
|
+
</div>
|
|
895
|
+
{/* Below the input and output, so an image that arrives in an open step grows in like its output and moves nothing above. */}
|
|
896
|
+
<Show when={image()}>
|
|
897
|
+
{(viewed) => <StepImage path={viewed().path} src={viewed().src} description={viewed().description} />}
|
|
898
|
+
</Show>
|
|
880
899
|
</div>
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
)}
|
|
885
|
-
|
|
900
|
+
)}
|
|
901
|
+
/>
|
|
902
|
+
{/* Each request a website approval let through is a step of its own, with its full URL. */}
|
|
903
|
+
<Show when={websiteReceipts(props.block).length > 0}>
|
|
904
|
+
<WebsiteReceipts block={props.block} />
|
|
905
|
+
</Show>
|
|
906
|
+
</>
|
|
886
907
|
);
|
|
887
908
|
}
|
|
@@ -12,7 +12,7 @@ import { AiChatActionsProvider, createAiChatTimeline } from "./presentation";
|
|
|
12
12
|
* `window.steer(text)` appends a pending steer as the controller does,
|
|
13
13
|
* `window.reconnect(on)` sets whether the stream reconnects,
|
|
14
14
|
* `window.continued` lists the messages that **Continue** sent,
|
|
15
|
-
* `window.approvals` lists the approval decisions, and
|
|
15
|
+
* `window.approvals` lists the approval decisions, `window.revoked` the websites a receipt revoked, and
|
|
16
16
|
* `window.opened` lists the chat files the timeline opened. Chat files load from `/files?path=`.
|
|
17
17
|
*/
|
|
18
18
|
const [state, setState] = createStore(emptyProjection());
|
|
@@ -20,9 +20,11 @@ const [reconnecting, setReconnecting] = createSignal(false);
|
|
|
20
20
|
const continued: string[] = [];
|
|
21
21
|
const opened: string[] = [];
|
|
22
22
|
const approvals: unknown[] = [];
|
|
23
|
+
const revoked: string[] = [];
|
|
23
24
|
Object.assign(window, {
|
|
24
25
|
continued,
|
|
25
26
|
approvals,
|
|
27
|
+
revoked,
|
|
26
28
|
opened,
|
|
27
29
|
reconnect: (on: boolean) => setReconnecting(on),
|
|
28
30
|
emit: (event: AiWireEvent) => setState(reconcile(reduceProjection(state, event), { key: "id", merge: true })),
|
|
@@ -42,6 +44,7 @@ render(
|
|
|
42
44
|
actions={{
|
|
43
45
|
onContinueTurn: (message) => void continued.push(message),
|
|
44
46
|
onApproval: async (request, input) => void approvals.push({ callId: request.callId, ...input }),
|
|
47
|
+
onRevokeWebsite: async (origin) => void revoked.push(origin),
|
|
45
48
|
fileUrl: (path) => `/files?path=${encodeURIComponent(path)}`,
|
|
46
49
|
onOpenFile: (path) => void opened.push(path),
|
|
47
50
|
}}
|
|
@@ -26,7 +26,7 @@ import { aiChatMessages } from "./messages";
|
|
|
26
26
|
/** The active-turn coordinates an approval/tool action needs to resolve on the server. */
|
|
27
27
|
export type AiTurnActionRequest = { turnId: string; callId: string; name: string };
|
|
28
28
|
|
|
29
|
-
type ApprovalHandler = (request: AiTurnActionRequest, input: { approved: boolean; remember?: "always" }) => void | Promise<void>;
|
|
29
|
+
type ApprovalHandler = (request: AiTurnActionRequest, input: { approved: boolean; remember?: "chat" | "always" }) => void | Promise<void>;
|
|
30
30
|
type FrontendToolResultHandler = (request: AiTurnActionRequest, result: unknown) => void | Promise<void>;
|
|
31
31
|
type ForkMessageHandler = (entry: AiStoredMessage, input?: AiForkMessageInput) => void | Promise<void>;
|
|
32
32
|
type RetryMessageHandler = (entry: AiStoredMessage, input?: AiRetryMessageInput) => void | Promise<void>;
|
|
@@ -42,6 +42,8 @@ export type AiChatActions = {
|
|
|
42
42
|
/** Prevents turn-continuation actions while the current turn is stopping. */
|
|
43
43
|
actionDisabled?: () => boolean;
|
|
44
44
|
onApproval?: ApprovalHandler;
|
|
45
|
+
/** Revoke the approval that lets this chat read `origin` without asking; the next request asks again. */
|
|
46
|
+
onRevokeWebsite?: (origin: string) => Promise<void>;
|
|
45
47
|
onFrontendToolResult?: FrontendToolResultHandler;
|
|
46
48
|
onForkMessage?: ForkMessageHandler;
|
|
47
49
|
onRetryMessage?: RetryMessageHandler;
|
package/src/ai/chat/messages.ts
CHANGED
|
@@ -98,6 +98,7 @@ const messages = i18n.define({
|
|
|
98
98
|
openImage: ({ name }: { name: string }) => `Open image ${name}`,
|
|
99
99
|
steered: "Conversation steered",
|
|
100
100
|
groupLoad: "Loaded tools and guidance",
|
|
101
|
+
repeatCount: ({ count }: { count: number }) => `${count} times`,
|
|
101
102
|
groupFailed: ({ count }: { count: number }) => `${count} failed`,
|
|
102
103
|
groupRejected: ({ count }: { count: number }) => `${count} rejected`,
|
|
103
104
|
receiptDone: ({ title }: { title: string }) => `Done: ${title}`,
|
|
@@ -120,6 +121,14 @@ const messages = i18n.define({
|
|
|
120
121
|
approved: "Approved",
|
|
121
122
|
rejected: "Rejected",
|
|
122
123
|
alwaysApprove: "Always approve",
|
|
124
|
+
chatApprove: "Approve for this chat",
|
|
125
|
+
allowWebsiteForChat: "Allow this website for this chat",
|
|
126
|
+
websiteAllowedForChat: ({ count }: { count: number }) =>
|
|
127
|
+
`Allowed for this chat · ${count === 1 ? "1 request without asking" : `${count} requests without asking`}`,
|
|
128
|
+
revoke: "Revoke",
|
|
129
|
+
revokeFailed: "Could not revoke. Try again.",
|
|
130
|
+
revokeWebsiteFor: ({ host }: { host: string }) => `Revoke the approval for ${host}`,
|
|
131
|
+
websiteRevoked: "Revoked. The next request asks again.",
|
|
123
132
|
details: "Details",
|
|
124
133
|
hideDetails: "Hide details",
|
|
125
134
|
moreOptions: ({ title }: { title: string }) => `More options for ${title}`,
|
|
@@ -389,6 +398,7 @@ const messages = i18n.define({
|
|
|
389
398
|
openImage: ({ name }) => `Bild ${name} öffnen`,
|
|
390
399
|
steered: "Richtung geändert",
|
|
391
400
|
groupLoad: "Werkzeuge und Wissen geladen",
|
|
401
|
+
repeatCount: ({ count }) => `${count}-mal`,
|
|
392
402
|
groupFailed: ({ count }) => `${count} fehlgeschlagen`,
|
|
393
403
|
groupRejected: ({ count }) => `${count} abgelehnt`,
|
|
394
404
|
receiptDone: ({ title }) => `Erledigt: ${title}`,
|
|
@@ -411,6 +421,14 @@ const messages = i18n.define({
|
|
|
411
421
|
approved: "Freigegeben",
|
|
412
422
|
rejected: "Abgelehnt",
|
|
413
423
|
alwaysApprove: "Immer freigeben",
|
|
424
|
+
chatApprove: "Für diesen Chat freigeben",
|
|
425
|
+
allowWebsiteForChat: "Diese Website für diesen Chat erlauben",
|
|
426
|
+
websiteAllowedForChat: ({ count }: { count: number }) =>
|
|
427
|
+
`Für diesen Chat erlaubt · ${count === 1 ? "1 Abruf ohne Rückfrage" : `${count} Abrufe ohne Rückfrage`}`,
|
|
428
|
+
revoke: "Entziehen",
|
|
429
|
+
revokeFailed: "Konnte nicht entzogen werden. Versuche es erneut.",
|
|
430
|
+
revokeWebsiteFor: ({ host }: { host: string }) => `Freigabe für ${host} entziehen`,
|
|
431
|
+
websiteRevoked: "Entzogen. Der nächste Abruf fragt wieder.",
|
|
414
432
|
details: "Details",
|
|
415
433
|
hideDetails: "Details ausblenden",
|
|
416
434
|
moreOptions: ({ title }) => `Weitere Optionen für ${title}`,
|
|
@@ -1,18 +1,20 @@
|
|
|
1
1
|
import { aiSkillFilePathFromMount } from "../file-mount";
|
|
2
2
|
import type { AiTurnBlock } from "../protocol";
|
|
3
|
+
import { isRecord } from "./message-utils";
|
|
3
4
|
import { aiChatMessages } from "./messages";
|
|
4
5
|
|
|
5
6
|
type Tool = Extract<AiTurnBlock, { kind: "tool" }>;
|
|
6
7
|
type Text = Extract<AiTurnBlock, { kind: "text" }>;
|
|
7
8
|
export type AiWorkEntry = Exclude<AiTurnBlock, { kind: "text" } | { kind: "steer_message" }>;
|
|
8
9
|
/**
|
|
9
|
-
* One row of the expanded work
|
|
10
|
-
*
|
|
10
|
+
* One row of the expanded work: an intermediate text, a step, or a group that folds housekeeping or repeated calls
|
|
11
|
+
* on one target and opens in place.
|
|
11
12
|
*/
|
|
12
13
|
export type AiWorkItem =
|
|
13
14
|
| { kind: "text"; block: Text }
|
|
14
15
|
| { kind: "step"; entry: AiWorkEntry }
|
|
15
|
-
| { kind: "housekeeping"; id: string; entries: AiWorkEntry[] }
|
|
16
|
+
| { kind: "housekeeping"; id: string; entries: AiWorkEntry[] }
|
|
17
|
+
| { kind: "repeat"; id: string; target: string; entries: AiWorkEntry[] };
|
|
16
18
|
|
|
17
19
|
/** A failed call, including a code run that reported an error. A rejected approval is a decision, not a failure. */
|
|
18
20
|
export function isFailedTool(tool: Tool): boolean {
|
|
@@ -50,33 +52,85 @@ export const isHousekeepingTool = (tool: Tool): boolean => {
|
|
|
50
52
|
|
|
51
53
|
const isStep = (entry: AiWorkEntry) => entry.kind === "tool" || entry.kind === "compaction";
|
|
52
54
|
|
|
55
|
+
/** The file, address, or query a folded row shows. A call without one never folds with others. */
|
|
56
|
+
const callTarget = (tool: Tool): string | undefined => {
|
|
57
|
+
const args = isRecord(tool.args) ? tool.args : {};
|
|
58
|
+
return [args.path, args.url, args.query].find((value): value is string => typeof value === "string" && value.trim() !== "")?.trim();
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
/** Arguments that page through one call's result, such as read_file's byte slices or an app's cursor and page size. */
|
|
62
|
+
const PAGING_ARGUMENTS = new Set(["offset", "length", "limit", "cursor", "after", "page", "pageSize"]);
|
|
63
|
+
|
|
64
|
+
type Fold = { kind: "housekeeping" } | { kind: "repeat"; name: string; target: string; identity: string };
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* What a step folds with: housekeeping with housekeeping, a running or finished call with the same call apart from
|
|
68
|
+
* paging, such as reading one file in parts. Repeated calls need a target; failed, rejected, and waiting calls never repeat.
|
|
69
|
+
*/
|
|
70
|
+
const foldOf = (tool: Tool): Fold | null => {
|
|
71
|
+
if (isHousekeepingTool(tool)) return { kind: "housekeeping" };
|
|
72
|
+
const target = callTarget(tool);
|
|
73
|
+
if (!target || isFailedTool(tool) || (tool.status !== "completed" && tool.status !== "running")) return null;
|
|
74
|
+
const args = isRecord(tool.args) ? tool.args : {};
|
|
75
|
+
const identity = JSON.stringify(
|
|
76
|
+
Object.fromEntries(Object.entries(args).filter(([key]) => !PAGING_ARGUMENTS.has(key))),
|
|
77
|
+
(_key: string, value: unknown) =>
|
|
78
|
+
typeof value === "string"
|
|
79
|
+
? value.trim()
|
|
80
|
+
: isRecord(value)
|
|
81
|
+
? Object.fromEntries(
|
|
82
|
+
Object.keys(value)
|
|
83
|
+
.sort()
|
|
84
|
+
.map((key) => [key, value[key]]),
|
|
85
|
+
)
|
|
86
|
+
: value,
|
|
87
|
+
);
|
|
88
|
+
return { kind: "repeat", name: tool.name, target, identity };
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
const sameFold = (a: Fold, b: Fold): boolean =>
|
|
92
|
+
a.kind === "housekeeping" ? b.kind === "housekeeping" : b.kind === "repeat" && a.name === b.name && a.identity === b.identity;
|
|
93
|
+
|
|
53
94
|
/**
|
|
54
|
-
* The rows of the expanded work, in the original order. A run of at least two
|
|
55
|
-
* between them,
|
|
56
|
-
*
|
|
57
|
-
*
|
|
58
|
-
*
|
|
95
|
+
* The rows of the expanded work, in the original order. A run of at least two steps that fold together, with the
|
|
96
|
+
* reasoning between them, becomes one row: housekeeping, or consecutive instances of the same call apart from paging,
|
|
97
|
+
* such as reading one file in parts. Anything else between them, such as an intermediate text, ends the run. A run that
|
|
98
|
+
* would hold all but one of the work's steps shows its steps directly, so the work line never opens to a summary of
|
|
99
|
+
* itself. Entries listed in `shown` already show as rows of their own and never fold, so nothing a reader sees folds
|
|
100
|
+
* away under them.
|
|
101
|
+
* A folded row is keyed by its first tool, whose id is the same live and in history.
|
|
59
102
|
*/
|
|
60
103
|
export function groupWorkBlocks(blocks: readonly AiTurnBlock[], shown: ReadonlySet<string> = new Set()): AiWorkItem[] {
|
|
61
104
|
const entries = blocks.filter((block): block is AiWorkEntry | Text => block.kind !== "steer_message");
|
|
62
105
|
const steps = entries.filter((entry) => entry.kind !== "text" && isStep(entry)).length;
|
|
63
106
|
const items: AiWorkItem[] = [];
|
|
107
|
+
let fold: Fold | null = null;
|
|
64
108
|
let run: AiWorkEntry[] = [];
|
|
65
109
|
let trailing: AiWorkEntry[] = [];
|
|
66
110
|
const flush = () => {
|
|
67
|
-
if (run.length > 0) {
|
|
111
|
+
if (fold && run.length > 0) {
|
|
68
112
|
const runSteps = run.filter(isStep).length;
|
|
69
|
-
|
|
113
|
+
const id = run[0]!.id;
|
|
114
|
+
if (runSteps >= 2 && steps - runSteps >= 2)
|
|
115
|
+
items.push(
|
|
116
|
+
fold.kind === "housekeeping"
|
|
117
|
+
? { kind: "housekeeping", id, entries: run }
|
|
118
|
+
: { kind: "repeat", id, target: fold.target, entries: run },
|
|
119
|
+
);
|
|
70
120
|
else for (const entry of run) items.push({ kind: "step", entry });
|
|
71
121
|
}
|
|
72
|
-
// Reasoning after the last
|
|
122
|
+
// Reasoning after the last folded step belongs to what comes next.
|
|
73
123
|
for (const entry of trailing) items.push({ kind: "step", entry });
|
|
124
|
+
fold = null;
|
|
74
125
|
run = [];
|
|
75
126
|
trailing = [];
|
|
76
127
|
};
|
|
77
128
|
for (const entry of entries) {
|
|
78
129
|
const foldable = !shown.has(entry.id);
|
|
79
|
-
|
|
130
|
+
const next = foldable && entry.kind === "tool" ? foldOf(entry) : null;
|
|
131
|
+
if (next && entry.kind === "tool") {
|
|
132
|
+
if (fold && !sameFold(fold, next)) flush();
|
|
133
|
+
fold = next;
|
|
80
134
|
run.push(...trailing, entry);
|
|
81
135
|
trailing = [];
|
|
82
136
|
} else if (foldable && entry.kind === "thinking" && run.length > 0) trailing.push(entry);
|
|
@@ -72,7 +72,8 @@ const toolNames = i18n.define({
|
|
|
72
72
|
code_restore: "Restore app version",
|
|
73
73
|
code_update: "Update app details",
|
|
74
74
|
code_create: "Create app",
|
|
75
|
-
code_write: "Save app
|
|
75
|
+
code_write: "Save app file",
|
|
76
|
+
code_import: "Import app files",
|
|
76
77
|
code_remove: "Remove app file",
|
|
77
78
|
},
|
|
78
79
|
de: {
|
|
@@ -140,7 +141,8 @@ const toolNames = i18n.define({
|
|
|
140
141
|
code_restore: "App-Version wiederherstellen",
|
|
141
142
|
code_update: "App-Details ändern",
|
|
142
143
|
code_create: "App erstellen",
|
|
143
|
-
code_write: "App-
|
|
144
|
+
code_write: "App-Datei speichern",
|
|
145
|
+
code_import: "App-Dateien importieren",
|
|
144
146
|
code_remove: "App-Datei entfernen",
|
|
145
147
|
},
|
|
146
148
|
},
|
|
@@ -35,6 +35,11 @@ export type AiTurnLayout = {
|
|
|
35
35
|
results: AiTurnResult[];
|
|
36
36
|
/** Place 3: the newest text. Live it is a status; finished it is the final message. */
|
|
37
37
|
text: TextBlock[];
|
|
38
|
+
/**
|
|
39
|
+
* Place 3 is only a status: the turn runs and a step started after its text. A trailing text may still be the answer
|
|
40
|
+
* and keeps answer typography; it turns quiet when the next step starts, together with the work line's new label.
|
|
41
|
+
*/
|
|
42
|
+
textIsStatus: boolean;
|
|
38
43
|
/** Place 4: effects in Cloud and decisions, open as a card or finished as a receipt. */
|
|
39
44
|
actions: AiTurnAction[];
|
|
40
45
|
/** The newest block, which names the current step of a live turn. */
|
|
@@ -124,6 +129,8 @@ export function layoutAiTurn(input: readonly AiTurnBlock[], options: { phase: Ai
|
|
|
124
129
|
}
|
|
125
130
|
}
|
|
126
131
|
const visible = new Set(text);
|
|
132
|
+
const lastStep = blocks.findLastIndex((block) => block.kind === "tool" || block.kind === "compaction");
|
|
133
|
+
const shownAt = text.length > 0 ? blocks.indexOf(text.at(-1)!) : -1;
|
|
127
134
|
const work = blocks.filter((block) => !visible.has(block as TextBlock));
|
|
128
135
|
|
|
129
136
|
const waits = tools.filter(waitsForUser);
|
|
@@ -135,6 +142,7 @@ export function layoutAiTurn(input: readonly AiTurnBlock[], options: { phase: Ai
|
|
|
135
142
|
showWork: steps > 0 || ((options.phase === "stopped" || options.phase === "failed") && work.length > 0),
|
|
136
143
|
results,
|
|
137
144
|
text,
|
|
145
|
+
textIsStatus: live(options.phase) && shownAt >= 0 && shownAt < lastStep,
|
|
138
146
|
actions,
|
|
139
147
|
current: blocks.at(-1) ?? null,
|
|
140
148
|
waitingFor,
|
|
@@ -144,7 +144,7 @@ const stepPhrase = (block: AiTurnBlock | null, t: Messages, withTargets: boolean
|
|
|
144
144
|
if (name === "code_present") return withTarget(t.stepPreparingView);
|
|
145
145
|
if (isCardToolName(name)) return t.stepPreparingCard;
|
|
146
146
|
if (isChartToolName(name)) return t.stepPreparingChart;
|
|
147
|
-
if (["code_write", "code_create", "code_update", "code_fork", "code_remove", "code_restore"].includes(name))
|
|
147
|
+
if (["code_write", "code_import", "code_create", "code_update", "code_fork", "code_remove", "code_restore"].includes(name))
|
|
148
148
|
return withTarget(t.stepWritingCode);
|
|
149
149
|
if (["code_run", "code_action", "code_sql"].includes(name)) return withTarget(t.stepRunningCode);
|
|
150
150
|
if (name === "code_check") return t.stepCheckingApp;
|
|
@@ -236,6 +236,7 @@ function AiWorkLine(props: { segment: Accessor<AiTurnSegment> }) {
|
|
|
236
236
|
tone={working() ? "ai" : "neutral"}
|
|
237
237
|
busy={working()}
|
|
238
238
|
trailing={trailing()}
|
|
239
|
+
bodyInset={false}
|
|
239
240
|
renderBody={() => <AiWorkSteps blocks={layout().work} active={working()} />}
|
|
240
241
|
/>
|
|
241
242
|
);
|
|
@@ -275,7 +276,27 @@ function WorkEntryRow(props: { entry: AiWorkEntry; busy: boolean }) {
|
|
|
275
276
|
);
|
|
276
277
|
}
|
|
277
278
|
|
|
278
|
-
/**
|
|
279
|
+
/** The steps of an opened group, keyed by block id, so a row keeps its element and focus when its block is replaced. */
|
|
280
|
+
function WorkEntries(props: { entries: AiWorkEntry[]; active: boolean }) {
|
|
281
|
+
const entries = createMemo(() => new Map(props.entries.map((entry) => [entry.id, entry])));
|
|
282
|
+
const ids = createMemo(() => [...entries().keys()]);
|
|
283
|
+
return (
|
|
284
|
+
<div class="ai-turn-steps">
|
|
285
|
+
<For each={ids()}>
|
|
286
|
+
{(id, index) => (
|
|
287
|
+
<Show when={entries().get(id)}>
|
|
288
|
+
{(entry) => <WorkEntryRow entry={entry()} busy={props.active && index() === ids().length - 1} />}
|
|
289
|
+
</Show>
|
|
290
|
+
)}
|
|
291
|
+
</For>
|
|
292
|
+
</div>
|
|
293
|
+
);
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/**
|
|
297
|
+
* Housekeeping folded into one row at step level, with its count at the end like the work line's. It opens in place:
|
|
298
|
+
* its steps follow at the same indent.
|
|
299
|
+
*/
|
|
279
300
|
function HousekeepingGroup(props: { id: string; entries: AiWorkEntry[]; active: boolean }) {
|
|
280
301
|
const locale = useLocale();
|
|
281
302
|
const last = () => props.entries.at(-1)!;
|
|
@@ -283,17 +304,33 @@ function HousekeepingGroup(props: { id: string; entries: AiWorkEntry[]; active:
|
|
|
283
304
|
<AiToolActivity
|
|
284
305
|
blockId={`group:${props.id}`}
|
|
285
306
|
label={aiChatMessages(locale()).groupLoad}
|
|
286
|
-
|
|
307
|
+
trailing={<span class="ai-turn-work__meta">{countWorkSteps(props.entries, locale())}</span>}
|
|
287
308
|
icon="ti ti-stack-2"
|
|
288
309
|
bodyInset={false}
|
|
289
310
|
busy={props.active && last().kind === "tool" && (last() as ToolBlock).status === "running"}
|
|
290
|
-
renderBody={() =>
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
311
|
+
renderBody={() => <WorkEntries entries={props.entries} active={props.active} />}
|
|
312
|
+
/>
|
|
313
|
+
);
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
/**
|
|
317
|
+
* Consecutive instances of one call apart from paging, such as reading one file in parts, folded into one row that
|
|
318
|
+
* opens in place. Its count sits at the end like the work line's, so a narrow screen cuts the target short, never the count.
|
|
319
|
+
*/
|
|
320
|
+
function RepeatedSteps(props: { id: string; target: string; entries: AiWorkEntry[]; active: boolean }) {
|
|
321
|
+
const locale = useLocale();
|
|
322
|
+
const tools = () => props.entries.filter((entry): entry is ToolBlock => entry.kind === "tool");
|
|
323
|
+
const first = () => tools()[0]!;
|
|
324
|
+
return (
|
|
325
|
+
<AiToolActivity
|
|
326
|
+
blockId={`repeat:${props.id}`}
|
|
327
|
+
label={first().presentation?.title ?? displayToolName(first().name, locale())}
|
|
328
|
+
description={props.target}
|
|
329
|
+
trailing={<span class="ai-turn-work__meta">{aiChatMessages(locale()).repeatCount({ count: tools().length })}</span>}
|
|
330
|
+
icon={aiToolIcon(first().name, first().presentation?.appIcon)}
|
|
331
|
+
bodyInset={false}
|
|
332
|
+
busy={props.active && tools().at(-1)?.status === "running"}
|
|
333
|
+
renderBody={() => <WorkEntries entries={props.entries} active={props.active} />}
|
|
297
334
|
/>
|
|
298
335
|
);
|
|
299
336
|
}
|
|
@@ -311,13 +348,19 @@ function WorkText(props: { text: string }) {
|
|
|
311
348
|
}
|
|
312
349
|
|
|
313
350
|
const workItemKey = (item: AiWorkItem): string =>
|
|
314
|
-
item.kind === "text"
|
|
351
|
+
item.kind === "text"
|
|
352
|
+
? `text:${item.block.id}`
|
|
353
|
+
: item.kind === "step"
|
|
354
|
+
? `step:${item.entry.id}`
|
|
355
|
+
: item.kind === "repeat"
|
|
356
|
+
? `repeat:${item.id}`
|
|
357
|
+
: `group:${item.id}`;
|
|
315
358
|
|
|
316
359
|
/**
|
|
317
|
-
* Expanded work,
|
|
318
|
-
* them directly, and housekeeping folded at step level. A row shown while this list is open never
|
|
319
|
-
* reader, also when reasoning or arguments that would fold it arrive later; the next time it
|
|
320
|
-
* history.
|
|
360
|
+
* Expanded work, at the work line's indent and one size: intermediate texts as quiet paragraphs, the steps between
|
|
361
|
+
* them directly, and housekeeping or repeated steps folded at step level. A row shown while this list is open never
|
|
362
|
+
* folds away under the reader, also when reasoning or arguments that would fold it arrive later; the next time it
|
|
363
|
+
* opens, it folds like in history.
|
|
321
364
|
*/
|
|
322
365
|
function AiWorkSteps(props: { blocks: AiTurnBlock[]; active: boolean }) {
|
|
323
366
|
const shown = new Set<string>();
|
|
@@ -344,12 +387,19 @@ function AiWorkSteps(props: { blocks: AiTurnBlock[]; active: boolean }) {
|
|
|
344
387
|
const value = item();
|
|
345
388
|
return value?.kind === "housekeeping" ? value : undefined;
|
|
346
389
|
};
|
|
390
|
+
const repeat = () => {
|
|
391
|
+
const value = item();
|
|
392
|
+
return value?.kind === "repeat" ? value : undefined;
|
|
393
|
+
};
|
|
347
394
|
const active = () => props.active && index() === keys().length - 1;
|
|
348
395
|
return (
|
|
349
396
|
<Switch>
|
|
350
397
|
<Match when={text()}>{(value) => <WorkText text={value()} />}</Match>
|
|
351
398
|
<Match when={step()}>{(entry) => <WorkEntryRow entry={entry()} busy={active()} />}</Match>
|
|
352
399
|
<Match when={group()}>{(value) => <HousekeepingGroup id={value().id} entries={value().entries} active={active()} />}</Match>
|
|
400
|
+
<Match when={repeat()}>
|
|
401
|
+
{(value) => <RepeatedSteps id={value().id} target={value().target} entries={value().entries} active={active()} />}
|
|
402
|
+
</Match>
|
|
353
403
|
</Switch>
|
|
354
404
|
);
|
|
355
405
|
}}
|
|
@@ -641,14 +691,18 @@ function AiTurnErrorNotice(props: { error: AiTurnError; continuable: boolean })
|
|
|
641
691
|
}
|
|
642
692
|
|
|
643
693
|
/**
|
|
644
|
-
* Place 3. Live it is a status that switches in this same element; it keeps its height until the turn ends.
|
|
645
|
-
*
|
|
694
|
+
* Place 3. Live it is a status that switches in this same element; it keeps its height until the turn ends. Once a step
|
|
695
|
+
* started after it, it reads quietly like the texts in the expanded work; a trailing text may still be the answer and
|
|
696
|
+
* keeps answer typography. A status is not read token by token: screen readers hear each status once it is complete,
|
|
697
|
+
* and that the answer is ready.
|
|
646
698
|
*/
|
|
647
699
|
function AiTurnText(props: {
|
|
648
700
|
blocks: Accessor<Extract<AiTurnBlock, { kind: "text" }>[]>;
|
|
649
701
|
live: Accessor<boolean>;
|
|
650
702
|
/** The turn has work steps, so its live text is a status rather than the answer itself. */
|
|
651
703
|
folding: Accessor<boolean>;
|
|
704
|
+
/** A step started after the text, so it can no longer be the answer: it reads quietly. */
|
|
705
|
+
quiet: Accessor<boolean>;
|
|
652
706
|
/** An earlier segment of a split turn: it stops being live while the turn goes on. */
|
|
653
707
|
earlier: Accessor<boolean>;
|
|
654
708
|
}) {
|
|
@@ -662,6 +716,7 @@ function AiTurnText(props: {
|
|
|
662
716
|
);
|
|
663
717
|
const html = createMemo(() => markdown.renderSync(source(), { locale: locale() }));
|
|
664
718
|
const shownId = createMemo(() => props.blocks()[0]?.id);
|
|
719
|
+
const quiet = createMemo(() => props.quiet());
|
|
665
720
|
const [minHeight, setMinHeight] = createSignal<number | null>(null);
|
|
666
721
|
let element!: HTMLDivElement;
|
|
667
722
|
let lastHeight = 0;
|
|
@@ -673,14 +728,21 @@ function AiTurnText(props: {
|
|
|
673
728
|
observer.observe(element);
|
|
674
729
|
onCleanup(() => observer.disconnect());
|
|
675
730
|
});
|
|
731
|
+
/** The quiet text's height in answer typography, measured in place before the browser paints. */
|
|
732
|
+
const answerHeight = () => {
|
|
733
|
+
element.classList.remove("ai-turn__text--status");
|
|
734
|
+
const height = element.offsetHeight;
|
|
735
|
+
element.classList.add("ai-turn__text--status");
|
|
736
|
+
return height;
|
|
737
|
+
};
|
|
738
|
+
// A new status keeps the height of the one before it. A quiet status keeps the height it has as the answer, also when
|
|
739
|
+
// it first shows quietly after a reload, so nothing below moves when it is replaced or becomes the answer again.
|
|
676
740
|
createEffect(
|
|
677
|
-
on(
|
|
678
|
-
|
|
679
|
-
()
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
{ defer: true },
|
|
683
|
-
),
|
|
741
|
+
on([shownId, quiet], ([id, isQuiet], previous) => {
|
|
742
|
+
if (!props.live()) return;
|
|
743
|
+
const height = Math.max(previous && previous[0] !== id ? lastHeight : 0, isQuiet ? answerHeight() : 0);
|
|
744
|
+
if (height > 0) setMinHeight((current) => Math.max(current ?? 0, height));
|
|
745
|
+
}),
|
|
684
746
|
);
|
|
685
747
|
createEffect(() => {
|
|
686
748
|
if (!props.live()) setMinHeight(null);
|
|
@@ -701,6 +763,7 @@ function AiTurnText(props: {
|
|
|
701
763
|
<div
|
|
702
764
|
ref={element}
|
|
703
765
|
class="ai-turn__text"
|
|
766
|
+
classList={{ "ai-turn__text--status": quiet() }}
|
|
704
767
|
aria-live={status() ? "off" : undefined}
|
|
705
768
|
style={minHeight() ? { "min-height": `${minHeight()}px` } : undefined}
|
|
706
769
|
>
|
|
@@ -757,7 +820,13 @@ export function AiTurnView(props: { segment: Accessor<AiTurnSegment>; disclosure
|
|
|
757
820
|
</Show>
|
|
758
821
|
<For each={resultIds()}>{(id) => <AiTurnResultView result={() => results().get(id)} receipt={() => actions().has(id)} />}</For>
|
|
759
822
|
<Show when={layout().text.length > 0}>
|
|
760
|
-
<AiTurnText
|
|
823
|
+
<AiTurnText
|
|
824
|
+
blocks={() => layout().text}
|
|
825
|
+
live={live}
|
|
826
|
+
folding={() => layout().steps > 0}
|
|
827
|
+
quiet={() => layout().textIsStatus}
|
|
828
|
+
earlier={() => props.segment().earlier}
|
|
829
|
+
/>
|
|
761
830
|
</Show>
|
|
762
831
|
<For each={actionIds()}>
|
|
763
832
|
{(id) => <AiTurnActionView action={() => actions().get(id)} turnId={props.segment().turnId} phase={props.segment().phase} />}
|