@k2b/cloud 0.25.0 → 0.26.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 +2 -2
- package/src/_internal/capabilities.ts +12 -0
- package/src/_internal/registry.ts +1 -0
- package/src/access/GroupCoverage.tsx +175 -0
- package/src/access/PermissionEditor.tsx +119 -99
- package/src/access/messages.ts +30 -0
- package/src/ai/approval-routes.ts +5 -5
- package/src/ai/capabilities.ts +34 -12
- package/src/ai/chat/blocks.tsx +86 -179
- package/src/ai/chat/builtin-tools.tsx +57 -30
- package/src/ai/chat/live-turn.browser-harness.tsx +37 -0
- package/src/ai/chat/message-actions.tsx +6 -2
- package/src/ai/chat/message-utils.ts +17 -14
- package/src/ai/chat/messages.ts +324 -2
- package/src/ai/chat/presentation.tsx +257 -103
- package/src/ai/chat/tool-groups.ts +55 -35
- package/src/ai/chat/turn-layout.ts +141 -0
- package/src/ai/chat/turn-view.tsx +609 -0
- package/src/ai/client/projection.ts +37 -6
- package/src/ai/executor.ts +38 -5
- package/src/ai/protocol.ts +20 -4
- package/src/ai/provider-fetch.ts +67 -15
- package/src/ai/provider-retry.ts +105 -0
- package/src/ai/quota-provider.ts +14 -5
- package/src/ai/store.ts +94 -3
- package/src/ai/stream.ts +2 -0
- package/src/ai/timeline.ts +9 -11
- package/src/ai/turn-timing.ts +31 -3
- package/src/ai/types.ts +12 -2
- package/src/contracts/registry.ts +2 -0
- package/src/shared/app-presentation.ts +10 -2
- package/src/styles/effects.css +69 -0
package/src/ai/store.ts
CHANGED
|
@@ -8,6 +8,7 @@ import { AI_MEMORY_LEARNING_DEFAULT_ENABLED } from "./prefs";
|
|
|
8
8
|
import type { AiTurnBlock } from "./protocol";
|
|
9
9
|
import { withAiShortId, withAiShortIdForDb } from "./short-id";
|
|
10
10
|
import { parseAiTodoPlan } from "./todo-contracts";
|
|
11
|
+
import { activeTurnWaits } from "./turn-timing";
|
|
11
12
|
import type {
|
|
12
13
|
AiConversation,
|
|
13
14
|
AiConversationDraft,
|
|
@@ -777,6 +778,76 @@ const messageSearchText = (message: Message): string => {
|
|
|
777
778
|
return text.slice(0, SEARCH_TEXT_MAX_CHARS);
|
|
778
779
|
};
|
|
779
780
|
|
|
781
|
+
/**
|
|
782
|
+
* Record how a turn ended on its messages when its loop could not record it: a stop while an approval waits, a run
|
|
783
|
+
* time limit, or a turn the sweep finalizes. History reads the ending from the last assistant message, so such a turn
|
|
784
|
+
* never looks finished. A failure replaces the `aborted` that a loop cut off by its run time limit recorded, so the
|
|
785
|
+
* limit never looks like a user stop. A call the user approved that never returned keeps its approval in history.
|
|
786
|
+
*/
|
|
787
|
+
const recordTurnEnd = async (
|
|
788
|
+
db: typeof sql,
|
|
789
|
+
input: { conversationId: string; turnId: string; reason: "aborted" | "error" },
|
|
790
|
+
): Promise<void> => {
|
|
791
|
+
const replaces = input.reason === "error" ? "aborted" : null;
|
|
792
|
+
for (const table of ["ai.messages", "ai.task_messages"]) {
|
|
793
|
+
await db`
|
|
794
|
+
UPDATE ${db(table)}
|
|
795
|
+
SET loop_done_reason = ${input.reason}
|
|
796
|
+
WHERE id = (
|
|
797
|
+
SELECT id
|
|
798
|
+
FROM ${db(table)}
|
|
799
|
+
WHERE conversation_id = ${input.conversationId}
|
|
800
|
+
AND loop_id = ${input.turnId}::text
|
|
801
|
+
AND compacted_at IS NULL
|
|
802
|
+
AND kind = 'message'
|
|
803
|
+
AND role = 'assistant'
|
|
804
|
+
ORDER BY seq DESC
|
|
805
|
+
LIMIT 1
|
|
806
|
+
)
|
|
807
|
+
AND (loop_done_reason IS NULL OR loop_done_reason = ${replaces}::text)
|
|
808
|
+
`;
|
|
809
|
+
// An approved call without a result keeps the decision on the message that holds the call. A decision on a custom
|
|
810
|
+
// approval belongs to the call that asked for it.
|
|
811
|
+
await db`
|
|
812
|
+
UPDATE ${db(table)} target
|
|
813
|
+
SET meta = jsonb_set(
|
|
814
|
+
COALESCE(target.meta, '{}'::jsonb),
|
|
815
|
+
'{toolOutcomes}',
|
|
816
|
+
COALESCE(target.meta->'toolOutcomes', '{}'::jsonb) || outcomes.value
|
|
817
|
+
)
|
|
818
|
+
FROM (
|
|
819
|
+
SELECT message.id, jsonb_object_agg(approved.call_id, 'approved'::text) AS value
|
|
820
|
+
FROM (
|
|
821
|
+
SELECT DISTINCT
|
|
822
|
+
CASE
|
|
823
|
+
WHEN action.kind = 'custom_approval' THEN COALESCE(substring(action.call_id FROM '^(.*)-approval-[0-9]+$'), action.call_id)
|
|
824
|
+
ELSE action.call_id
|
|
825
|
+
END AS call_id
|
|
826
|
+
FROM ai.pending_actions action
|
|
827
|
+
WHERE action.turn_id = ${input.turnId}
|
|
828
|
+
AND action.resolved_event->>'type' = 'approval_response'
|
|
829
|
+
AND action.resolved_event->>'approved' = 'true'
|
|
830
|
+
) approved
|
|
831
|
+
JOIN ${db(table)} message
|
|
832
|
+
ON message.conversation_id = ${input.conversationId}
|
|
833
|
+
AND message.loop_id = ${input.turnId}::text
|
|
834
|
+
AND message.role = 'assistant'
|
|
835
|
+
AND message.message->'content' @> jsonb_build_array(jsonb_build_object('type', 'tool_call', 'id', approved.call_id))
|
|
836
|
+
WHERE NOT EXISTS (
|
|
837
|
+
SELECT 1
|
|
838
|
+
FROM ${db(table)} result
|
|
839
|
+
WHERE result.conversation_id = ${input.conversationId}
|
|
840
|
+
AND result.loop_id = ${input.turnId}::text
|
|
841
|
+
AND result.role = 'tool_result'
|
|
842
|
+
AND result.message->>'callId' = approved.call_id
|
|
843
|
+
)
|
|
844
|
+
GROUP BY message.id
|
|
845
|
+
) outcomes
|
|
846
|
+
WHERE target.id = outcomes.id
|
|
847
|
+
`;
|
|
848
|
+
}
|
|
849
|
+
};
|
|
850
|
+
|
|
780
851
|
/** Insert a message inside an open conversation-lock transaction and bump the conversation. */
|
|
781
852
|
const insertMessageLocked = async (
|
|
782
853
|
input: {
|
|
@@ -925,10 +996,14 @@ const toolMessageMeta = (
|
|
|
925
996
|
message: Message,
|
|
926
997
|
presentations: ReadonlyMap<string, AiToolPresentation> | undefined,
|
|
927
998
|
rejectedToolCallIds: ReadonlySet<string> | undefined,
|
|
999
|
+
approvedToolCallIds?: ReadonlySet<string>,
|
|
928
1000
|
): AiStoredMessage["meta"] => {
|
|
929
1001
|
if (message.role === "tool_result" && rejectedToolCallIds?.has(message.callId)) {
|
|
930
1002
|
return { toolOutcomes: { [message.callId]: "rejected" } };
|
|
931
1003
|
}
|
|
1004
|
+
if (message.role === "tool_result" && approvedToolCallIds?.has(message.callId)) {
|
|
1005
|
+
return { toolOutcomes: { [message.callId]: "approved" } };
|
|
1006
|
+
}
|
|
932
1007
|
if (message.role !== "assistant" || !presentations || presentations.size === 0) return null;
|
|
933
1008
|
const toolPresentations = Object.fromEntries(
|
|
934
1009
|
message.content.flatMap((block) => {
|
|
@@ -2618,10 +2693,14 @@ export const aiConversations: AiConversationService = {
|
|
|
2618
2693
|
LIMIT 1
|
|
2619
2694
|
`;
|
|
2620
2695
|
if (!rows[0]) return null;
|
|
2696
|
+
// The waits let a reconnecting client show work time without time spent waiting for the user.
|
|
2697
|
+
const waits = await activeTurnWaits(rows[0].id);
|
|
2621
2698
|
return {
|
|
2622
2699
|
turn: rowToTurn(rows[0]),
|
|
2623
2700
|
liveBlocks: rowToLiveBlocks(rows[0]),
|
|
2624
2701
|
liveSeq: Number(rows[0].live_seq ?? 0),
|
|
2702
|
+
actionWaitMs: Math.max(0, Math.round(waits.actionWaitMs)),
|
|
2703
|
+
waitingSince: waits.waitingSince,
|
|
2625
2704
|
};
|
|
2626
2705
|
},
|
|
2627
2706
|
|
|
@@ -2822,6 +2901,11 @@ export const aiConversations: AiConversationService = {
|
|
|
2822
2901
|
AND status = 'pending'
|
|
2823
2902
|
`;
|
|
2824
2903
|
if (input.status !== "completed") {
|
|
2904
|
+
await recordTurnEnd(tx, {
|
|
2905
|
+
conversationId: input.conversationId,
|
|
2906
|
+
turnId: input.turnId,
|
|
2907
|
+
reason: input.status === "aborted" ? "aborted" : "error",
|
|
2908
|
+
});
|
|
2825
2909
|
await tx`
|
|
2826
2910
|
UPDATE ai.turn_steers
|
|
2827
2911
|
SET status = 'discarded', consumed_at = COALESCE(consumed_at, now())
|
|
@@ -2922,7 +3006,7 @@ export const aiConversations: AiConversationService = {
|
|
|
2922
3006
|
}));
|
|
2923
3007
|
|
|
2924
3008
|
// 3) Finalize aborts: cancel-requested turns without a live lease, and expired waits.
|
|
2925
|
-
const abortedRows = await sql<{ id: string; conversation_id: string; attempt: number; live_seq: number | string }[]>`
|
|
3009
|
+
const abortedRows = await sql<{ id: string; conversation_id: string; attempt: number; live_seq: number | string; stopped: boolean }[]>`
|
|
2926
3010
|
UPDATE ai.turns
|
|
2927
3011
|
SET status = 'aborted',
|
|
2928
3012
|
completed_at = now(),
|
|
@@ -2939,7 +3023,7 @@ export const aiConversations: AiConversationService = {
|
|
|
2939
3023
|
)
|
|
2940
3024
|
LIMIT ${limit}
|
|
2941
3025
|
)
|
|
2942
|
-
RETURNING id, conversation_id, attempt, live_seq
|
|
3026
|
+
RETURNING id, conversation_id, attempt, live_seq, cancel_requested_at IS NOT NULL AS stopped
|
|
2943
3027
|
`;
|
|
2944
3028
|
result.aborted = abortedRows.map((row) => ({
|
|
2945
3029
|
conversationId: row.conversation_id,
|
|
@@ -2948,7 +3032,14 @@ export const aiConversations: AiConversationService = {
|
|
|
2948
3032
|
seq: Number(row.live_seq) + 1,
|
|
2949
3033
|
}));
|
|
2950
3034
|
|
|
3035
|
+
// History tells a stop from a turn that failed or whose wait expired, as it does for turns that end on their own.
|
|
3036
|
+
const stopped = new Set(abortedRows.filter((row) => row.stopped).map((row) => row.id));
|
|
2951
3037
|
for (const finalized of [...result.failed, ...result.aborted]) {
|
|
3038
|
+
await recordTurnEnd(sql, {
|
|
3039
|
+
conversationId: finalized.conversationId,
|
|
3040
|
+
turnId: finalized.turnId,
|
|
3041
|
+
reason: stopped.has(finalized.turnId) ? "aborted" : "error",
|
|
3042
|
+
});
|
|
2952
3043
|
await sql`
|
|
2953
3044
|
UPDATE ai.pending_actions
|
|
2954
3045
|
SET status = 'aborted', resolved_at = COALESCE(resolved_at, now())
|
|
@@ -3300,7 +3391,7 @@ export const aiConversations: AiConversationService = {
|
|
|
3300
3391
|
append: async (message, opts) => {
|
|
3301
3392
|
// Initial input and durable steering are already persisted transactionally before Nessi appends them.
|
|
3302
3393
|
if (message.role === "user") return;
|
|
3303
|
-
const meta = toolMessageMeta(message, input.toolPresentations, input.rejectedToolCallIds);
|
|
3394
|
+
const meta = toolMessageMeta(message, input.toolPresentations, input.rejectedToolCallIds, input.approvedToolCallIds);
|
|
3304
3395
|
|
|
3305
3396
|
if (input.turnId && input.leaseOwner) {
|
|
3306
3397
|
const appended = await appendTurnOwnedMessage({
|
package/src/ai/stream.ts
CHANGED
|
@@ -94,6 +94,8 @@ const turnSnapshotFromActive = (active: NonNullable<Awaited<ReturnType<typeof ai
|
|
|
94
94
|
blocks: [...active.liveBlocks],
|
|
95
95
|
modelProfileId: active.turn.modelProfileId,
|
|
96
96
|
createdAt: active.turn.createdAt,
|
|
97
|
+
actionWaitMs: active.actionWaitMs,
|
|
98
|
+
waitingSince: active.waitingSince,
|
|
97
99
|
});
|
|
98
100
|
|
|
99
101
|
/** Initial history window; older messages load on demand while scrolling up. */
|
package/src/ai/timeline.ts
CHANGED
|
@@ -12,9 +12,9 @@ export type AiAssistantTimelineItem = {
|
|
|
12
12
|
/** The entry whose message-actions row (copy/retry/fork) is shown. */
|
|
13
13
|
actionEntry: AiStoredMessage | null;
|
|
14
14
|
/**
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
* message
|
|
15
|
+
* Work time of the loop: wall time minus time spent waiting for user actions,
|
|
16
|
+
* from the loop's durable timing. Without timing, the time from the user
|
|
17
|
+
* message to the last persisted message.
|
|
18
18
|
*/
|
|
19
19
|
workedMs: number;
|
|
20
20
|
};
|
|
@@ -32,13 +32,6 @@ export const assistantVisibleTextFromMessage = (message: Message): string => {
|
|
|
32
32
|
.trim();
|
|
33
33
|
};
|
|
34
34
|
|
|
35
|
-
export const copyTextFromAssistantEntries = (entries: AiStoredMessage[]): string =>
|
|
36
|
-
entries
|
|
37
|
-
.map((entry) => assistantVisibleTextFromMessage(entry.message))
|
|
38
|
-
.filter(Boolean)
|
|
39
|
-
.join("\n\n")
|
|
40
|
-
.trim();
|
|
41
|
-
|
|
42
35
|
const isAssistantPart = (entry: AiStoredMessage): boolean =>
|
|
43
36
|
entry.kind === "message" && (entry.message.role === "assistant" || entry.message.role === "tool_result");
|
|
44
37
|
|
|
@@ -123,7 +116,12 @@ export const buildAiMessageTimeline = (messages: AiStoredMessage[]): AiMessageTi
|
|
|
123
116
|
const startedAt =
|
|
124
117
|
loopId && lastUserEntry?.loopId === loopId ? timestampMs(lastUserEntry.createdAt) : timestampMs(entries[0]?.createdAt);
|
|
125
118
|
const finishedAt = timestampMs(entries.at(-1)?.createdAt);
|
|
126
|
-
const
|
|
119
|
+
const timing = entries.findLast((stored) => stored.loopAggregate?.timing)?.loopAggregate?.timing;
|
|
120
|
+
const workedMs = timing
|
|
121
|
+
? Math.max(0, timing.wallMs - timing.actionWaitMs)
|
|
122
|
+
: startedAt !== null && finishedAt !== null
|
|
123
|
+
? Math.max(0, finishedAt - startedAt)
|
|
124
|
+
: 0;
|
|
127
125
|
|
|
128
126
|
const blocks = [
|
|
129
127
|
...steerBlocks,
|
package/src/ai/turn-timing.ts
CHANGED
|
@@ -80,6 +80,36 @@ export function createTurnTimingRecorder(turnId: string, db = sql) {
|
|
|
80
80
|
};
|
|
81
81
|
}
|
|
82
82
|
|
|
83
|
+
/**
|
|
84
|
+
* Time a turn waited for the person: approvals and answers until they were given. A tool the browser runs by itself
|
|
85
|
+
* waits only until it starts; a secret prompt waits for its answer even though the browser claimed it. An open wait
|
|
86
|
+
* ends now.
|
|
87
|
+
*/
|
|
88
|
+
const actionWaits = (turnId: string, db: typeof sql) =>
|
|
89
|
+
db<{ start: Date; end: Date; open: boolean }[]>`SELECT a.created_at AS start,
|
|
90
|
+
COALESCE(w.end, now()) AS end, w.end IS NULL AS open
|
|
91
|
+
FROM ai.pending_actions a LEFT JOIN ai.tool_calls t ON t.turn_id=a.turn_id AND t.call_id=a.call_id
|
|
92
|
+
CROSS JOIN LATERAL (SELECT CASE WHEN a.kind='client_tool' AND a.tool_name<>'code_secret' THEN LEAST(t.started_at,a.resolved_at)
|
|
93
|
+
ELSE a.resolved_at END AS end) w
|
|
94
|
+
WHERE a.turn_id=${turnId}::uuid`;
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Waits of a running turn by the same rules as its durable timing: `actionWaitMs` is the time already waited, and
|
|
98
|
+
* `waitingSince` the start of the wait that is still open, overlapping waits counted once.
|
|
99
|
+
*/
|
|
100
|
+
export async function activeTurnWaits(turnId: string, db = sql): Promise<{ actionWaitMs: number; waitingSince: string | null }> {
|
|
101
|
+
const rows = await actionWaits(turnId, db);
|
|
102
|
+
const waits = union(rows.map((x) => ({ start: new Date(x.start).getTime(), end: new Date(x.end).getTime() })));
|
|
103
|
+
const opens = rows.filter((x) => x.open).map((x) => new Date(x.start).getTime());
|
|
104
|
+
const openStart = opens.length > 0 ? Math.min(...opens) : null;
|
|
105
|
+
// An open wait reaches until now, so the merged wait that holds it is the last one.
|
|
106
|
+
const current = openStart === null ? undefined : waits.find((x) => x.start <= openStart && openStart <= x.end);
|
|
107
|
+
return {
|
|
108
|
+
actionWaitMs: duration(waits.filter((x) => x !== current)),
|
|
109
|
+
waitingSince: current ? new Date(current.start).toISOString() : null,
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
|
|
83
113
|
export async function withDurableTurnTiming(turnId: string, aggregate: LoopAggregate, db = sql): Promise<LoopAggregate> {
|
|
84
114
|
const [turn] = await db<
|
|
85
115
|
{ start: Date; end: Date }[]
|
|
@@ -95,9 +125,7 @@ export async function withDurableTurnTiming(turnId: string, aggregate: LoopAggre
|
|
|
95
125
|
const tools = await db<
|
|
96
126
|
{ start: Date; end: Date }[]
|
|
97
127
|
>`SELECT started_at AS start,COALESCE(completed_at,now()) AS end FROM ai.tool_calls WHERE turn_id=${turnId}::uuid AND started_at IS NOT NULL`;
|
|
98
|
-
const waits = await db
|
|
99
|
-
CASE WHEN a.kind='client_tool' THEN LEAST(COALESCE(t.started_at,a.resolved_at,now()),COALESCE(a.resolved_at,now())) ELSE COALESCE(a.resolved_at,now()) END AS end
|
|
100
|
-
FROM ai.pending_actions a LEFT JOIN ai.tool_calls t ON t.turn_id=a.turn_id AND t.call_id=a.call_id WHERE a.turn_id=${turnId}::uuid`;
|
|
128
|
+
const waits = await actionWaits(turnId, db);
|
|
101
129
|
const intervals = (rows: { start: Date; end: Date | null }[]) =>
|
|
102
130
|
rows.flatMap((x) => (x.end ? [{ start: new Date(x.start).getTime(), end: new Date(x.end).getTime() }] : []));
|
|
103
131
|
return {
|
package/src/ai/types.ts
CHANGED
|
@@ -296,7 +296,7 @@ export type AiStoredMessage = {
|
|
|
296
296
|
trigger: "scheduled" | "manual";
|
|
297
297
|
};
|
|
298
298
|
toolPresentations?: Record<string, AiToolPresentation>;
|
|
299
|
-
toolOutcomes?: Record<string, "rejected">;
|
|
299
|
+
toolOutcomes?: Record<string, "rejected" | "approved">;
|
|
300
300
|
} | null;
|
|
301
301
|
/** Private owner feedback for this rendered assistant response. Never enters model context. */
|
|
302
302
|
feedback?: AiMessageFeedback | null;
|
|
@@ -864,7 +864,15 @@ export type AiConversationService = {
|
|
|
864
864
|
getLatestTurn(input: { conversationId: string }): Promise<AiTurn | null>;
|
|
865
865
|
getTurn(input: { conversationId: string; turnId: string }): Promise<AiTurn | null>;
|
|
866
866
|
getTurnByShortId(input: { conversationId: string; shortId: string }): Promise<AiTurn | null>;
|
|
867
|
-
getActiveTurn(input: { conversationId: string }): Promise<{
|
|
867
|
+
getActiveTurn(input: { conversationId: string }): Promise<{
|
|
868
|
+
turn: AiTurn;
|
|
869
|
+
liveBlocks: AiTurnBlock[];
|
|
870
|
+
liveSeq: number;
|
|
871
|
+
/** Time spent waiting for answered user actions. */
|
|
872
|
+
actionWaitMs: number;
|
|
873
|
+
/** Start of the oldest unanswered user action, if the turn waits now. */
|
|
874
|
+
waitingSince: string | null;
|
|
875
|
+
} | null>;
|
|
868
876
|
/**
|
|
869
877
|
* Claim a turn attempt. Increments attempt and takes the lease atomically.
|
|
870
878
|
* `from: "queue"` claims queued or lease-expired running turns; `from: "waiting"`
|
|
@@ -944,6 +952,8 @@ export type AiConversationService = {
|
|
|
944
952
|
toolPresentations?: ReadonlyMap<string, AiToolPresentation>;
|
|
945
953
|
/** Mutable call-id set read only when a rejected tool result is persisted. */
|
|
946
954
|
rejectedToolCallIds?: ReadonlySet<string>;
|
|
955
|
+
/** Mutable call-id set read only when the result of a call the user approved is persisted. */
|
|
956
|
+
approvedToolCallIds?: ReadonlySet<string>;
|
|
947
957
|
}): SessionStore;
|
|
948
958
|
};
|
|
949
959
|
|
|
@@ -38,6 +38,8 @@ export type CapabilityRegistryEntry = {
|
|
|
38
38
|
endpoint: string;
|
|
39
39
|
manifest: CapabilityManifest;
|
|
40
40
|
presentation?: CapabilityPresentationCatalog;
|
|
41
|
+
/** The app's own name and description overlays, so capability consumers can present the app in the reader's locale. */
|
|
42
|
+
appPresentation?: AppPresentationCatalog;
|
|
41
43
|
};
|
|
42
44
|
|
|
43
45
|
export type AppRegistryHelpSummary = { manifestHash: string; pageBase: string; baseLocale: string };
|
|
@@ -27,6 +27,15 @@ const mergedTranslation = (catalog: AppPresentationCatalog, requestedLocale: str
|
|
|
27
27
|
}, {});
|
|
28
28
|
};
|
|
29
29
|
|
|
30
|
+
/** Resolve only an app's display name and description, for consumers that carry no other app metadata. */
|
|
31
|
+
export const resolveAppIdentityPresentation = (
|
|
32
|
+
app: { name: string; description: string; presentation?: AppPresentationCatalog },
|
|
33
|
+
requestedLocale: string,
|
|
34
|
+
): { name: string; description: string } => {
|
|
35
|
+
const translation = app.presentation ? mergedTranslation(app.presentation, requestedLocale) : {};
|
|
36
|
+
return { name: translation.name ?? app.name, description: translation.description ?? app.description };
|
|
37
|
+
};
|
|
38
|
+
|
|
30
39
|
/** Resolve only human presentation; stable app identity, routes, icons, and authorization stay untouched. */
|
|
31
40
|
export const resolveAppPresentation = <T extends AppMeta>(app: T, requestedLocale: string): T => {
|
|
32
41
|
if (!app.presentation) return app;
|
|
@@ -34,8 +43,7 @@ export const resolveAppPresentation = <T extends AppMeta>(app: T, requestedLocal
|
|
|
34
43
|
if (Object.keys(translation).length === 0) return app;
|
|
35
44
|
return {
|
|
36
45
|
...app,
|
|
37
|
-
|
|
38
|
-
description: translation.description ?? app.description,
|
|
46
|
+
...resolveAppIdentityPresentation(app, requestedLocale),
|
|
39
47
|
adminNav: app.adminNav?.map((group) => ({
|
|
40
48
|
...group,
|
|
41
49
|
label: (group.id && translation.adminGroups?.[group.id]) || group.label,
|
package/src/styles/effects.css
CHANGED
|
@@ -27,6 +27,75 @@
|
|
|
27
27
|
max-width: 100%;
|
|
28
28
|
}
|
|
29
29
|
|
|
30
|
+
/* One assistant turn in four fixed places: work line, results, newest text, actions. */
|
|
31
|
+
.k2b-ui .ai-turn {
|
|
32
|
+
display: flex;
|
|
33
|
+
min-width: 0;
|
|
34
|
+
flex-direction: column;
|
|
35
|
+
gap: 0.5rem;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
.k2b-ui .ai-turn__work {
|
|
39
|
+
min-width: 0;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
.k2b-ui .ai-turn-work__meta {
|
|
43
|
+
font-variant-numeric: tabular-nums;
|
|
44
|
+
white-space: nowrap;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/* Phones keep the clock while the turn runs; the step count follows once it is finished. */
|
|
48
|
+
@media (max-width: 40rem) {
|
|
49
|
+
.k2b-ui .ai-turn-work__live-steps {
|
|
50
|
+
display: none;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/* Expanded work: one indent, one size, no lines or boxes; intermediate texts are quiet paragraphs. */
|
|
55
|
+
.k2b-ui .ai-turn-steps {
|
|
56
|
+
display: flex;
|
|
57
|
+
min-width: 0;
|
|
58
|
+
flex-direction: column;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
.k2b-ui .ai-turn-steps__text {
|
|
62
|
+
padding-block: 0.25rem;
|
|
63
|
+
color: var(--k2b-text-muted);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
.k2b-ui .ai-turn-steps__text .assistant-markdown-block,
|
|
67
|
+
.k2b-ui .ai-turn-steps__text .assistant-markdown-block :where(h1, h2, h3, h4, h5, h6) {
|
|
68
|
+
color: inherit;
|
|
69
|
+
font-size: 0.75rem;
|
|
70
|
+
line-height: 1.55;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
.k2b-ui .ai-turn-steps__text .assistant-markdown-block :where(p, ul, ol, pre, blockquote) {
|
|
74
|
+
margin-block: 0.375rem;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
.k2b-ui .ai-turn-steps__thought {
|
|
78
|
+
margin: 0;
|
|
79
|
+
color: var(--k2b-text-muted);
|
|
80
|
+
white-space: pre-wrap;
|
|
81
|
+
overflow-wrap: anywhere;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/* A decided approval keeps focus on its receipt; the place only shows a ring for keyboard focus. */
|
|
85
|
+
.k2b-ui .ai-turn__action {
|
|
86
|
+
min-width: 0;
|
|
87
|
+
border-radius: var(--k2b-radius-control);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
.k2b-ui .ai-turn__action:focus {
|
|
91
|
+
outline: none;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
.k2b-ui .ai-turn__action:focus-visible {
|
|
95
|
+
outline: var(--k2b-focus-width) solid var(--k2b-focus-ring);
|
|
96
|
+
outline-offset: var(--k2b-focus-offset);
|
|
97
|
+
}
|
|
98
|
+
|
|
30
99
|
/* Result links sit inside an already padded activity row. Keep them inline so
|
|
31
100
|
they do not make linked capability results taller than neighboring rows. */
|
|
32
101
|
.k2b-ui .k2b-button.ai-chat-result-link[data-size="xs"] {
|