@hanzo/rooms 0.1.37 → 0.1.39

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/Chat.js CHANGED
@@ -30,40 +30,36 @@ import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-run
30
30
  import { useCallback, useContext, useEffect, useMemo, useRef, useState } from "react";
31
31
  import { billed, detail, familyOf, planRequired, plainly, refused } from "./failure.js";
32
32
  import { Prose } from "./Prose.js";
33
- import { useAgent, useAi, useChat, useModels, } from "@hanzo/ai/react";
33
+ import { useAgent, useAi, useChat, } from "@hanzo/ai/react";
34
34
  import { freeCopy, grantConsent, hasConsent, isFree, } from "@hanzo/ai";
35
- import { ASK, Chat as ChatSurface, Code, Failure, Parts, words } from "@hanzo/ui/chat";
35
+ import { ASK, Chat as ChatSurface, Failure, Parts, words } from "@hanzo/ui/chat";
36
36
  import { carriedIn, compose, partsOf, said as told, weigh } from "./lib/attach.js";
37
37
  import { download, dropped, fileOf, pct, readable, refOf, retrieve } from "./lib/files.js";
38
38
  import { channel, drop, hold, landed, localOf, spend, take, usePane, useWorkFile } from "./pane.js";
39
39
  import { Face, brief as roomBrief, join, roleOf, roomTurn, roster as rosterLine, rosterOf, speakers, speaksOf, voiceOf } from "./cast.js";
40
- import { SpeakButton, cleanForSpeech } from "./speech.js";
40
+ import { cleanForSpeech, speakAgent } from "./speech.js";
41
41
  import { Crew, HOUSE, useCrew } from "./crew.js";
42
42
  import { called } from "./team.js";
43
- import { Anchor, Box, Button, Text, Tooltip, TooltipContent, TooltipTrigger, View, XStack, YStack } from "@hanzo/ui";
44
- import { Control } from "@hanzo/composer";
43
+ import { Anchor, Box, Button, Text, View, XStack, YStack } from "@hanzo/ui";
44
+ import { useMedia } from "@hanzo/gui";
45
45
  import { useIam, useIamToken } from "@hanzo/iam/react";
46
- import { hasSession, org } from "./lib/session.js";
47
- import { chats, house, paidPlan, setCreditsAfterAllowance, useLimits } from "./lib/limits.js";
48
- import { parseModels } from "@hanzo/ui/models/catalog";
46
+ import { bearer, hasSession, org } from "./lib/session.js";
47
+ import { api } from "./lib/api.js";
48
+ import { chats, house, paidPlan, paused, setCreditsAfterAllowance, useLimits } from "./lib/limits.js";
49
49
  import { LimitedBanner } from "@hanzo/ui/product/LimitedBanner";
50
50
  import { WEB } from "./lib/web.js";
51
51
  import { useHydrated } from "./lib/hydrated.js";
52
52
  import { base, served } from "./lib/ai.js";
53
53
  import { onServed } from "./lib/served.js";
54
- import { refused as worded, speech, useDictation, useTalk, useVoice, Voice } from "@hanzo/voice";
55
- import { ArrowUp, AudioLines, FileText, Image as ImageMark, Mic, Paperclip, Pin, Square, PanelRight, X } from "lucide-react";
56
- import { ENSO, FREE } from "./lib/ai.js";
54
+ import { refused as worded, speech, useDictation, useTalk, useVoice } from "@hanzo/voice";
55
+ import { FileText, Image as ImageMark, Paperclip, Pin, X } from "lucide-react";
56
+ import { FREE } from "./lib/ai.js";
57
57
  import { openThread, useOpen } from "./open.js";
58
58
  import { Beside, Framed } from "./Shell.js";
59
- import { pane } from "./ground.js";
60
- import { RightPane, addressOf, look, usePinned } from "./RightPane.js";
61
- import { useMedia } from "@hanzo/gui";
62
- import { Take } from "./copy.js";
59
+ import { SEED, look, useKinds } from "./RightPane.js";
63
60
  import { Share } from "./Share.js";
64
61
  import { useThreadActions, useThreadList } from "./thread.js";
65
- import { useModel } from "./model.js";
66
- import { Enso, nameOf, useEffort } from "./enso.js";
62
+ import { artifacts, lead, nameOf, PAGE, Panel, PanelToggle, prompt, renders, Reply, Served, useDeck, useKey, useMind, usePanel, } from "@hanzo/build";
67
63
  import { checkoutUrl } from './lib/pay.js';
68
64
  import { Upgrade, useFree } from './Upgrade.js';
69
65
  import { enter, LOGIN } from './lib/destination.js';
@@ -180,9 +176,6 @@ function useSeed(initial, set) {
180
176
  apply.current(initial);
181
177
  }, [initial]);
182
178
  }
183
- /** The circle both controls are cut from: @hanzo/composer's `Control`, at the
184
- * 30px a line of this composer's footer row holds. */
185
- const ROUND = 30;
186
179
  export function Chat() {
187
180
  const { isAuthenticated, isLoading } = useIam();
188
181
  const hydrated = useHydrated();
@@ -486,10 +479,11 @@ function Thread({ placeholder = message(), initial, anonymous = false, heading,
486
479
  * that settles after mount. Picking from the menu still wins, permanently.
487
480
  */
488
481
  const [asking, setAsking] = useState(null);
489
- // The setter is taken now, not just the value: the override moved from
490
- // Settings into the composer's own panel, so this is where it is written.
491
- const [preferred, setPreferred] = useModel();
492
- const [effort, setEffort] = useEffort();
482
+ // THE MODEL AND THE EFFORT are the one choice Chat and Dev share
483
+ // (@hanzo/build `useMind`): the catalog from `GET /v1/models` and what the
484
+ // reader kept, changed from the composer's chip or from Settings.
485
+ const target = useMemo(() => ({ api: api(), token: bearer, org: org() }), [anonymous]); // eslint-disable-line react-hooks/exhaustive-deps
486
+ const mind = useMind(target);
493
487
  const ai = useAi();
494
488
  // In the app's frame the composer floats on the pane as a pane of its own.
495
489
  const framed = useContext(Framed);
@@ -506,33 +500,45 @@ function Thread({ placeholder = message(), initial, anonymous = false, heading,
506
500
  const at = channel(opened);
507
501
  // The first of them: what a surface that addresses one person reads.
508
502
  const withWhom = room[0] ?? null;
509
- // Whether the column beside this conversation is showing its summary, and the
510
- // switch for it. Kept per conversation by `pane.ts`, so the header's control
511
- // reflects THIS room rather than the last one that was open.
512
- const [pinned, flipPin] = usePinned();
513
- // The frame draws the column only from `$lg` (Shell.tsx `Paned`).
503
+ // THE SIDE PANEL (@hanzo/build `Panel`), the one Dev's runs open too: open or
504
+ // shut as Chat was left on a laptop, a sheet asked for on a phone, its tabs
505
+ // kept per conversation. The header's Panel button and ⌘. move it.
514
506
  const wide = useMedia().lg;
507
+ const [panel, setPanel] = usePanel("chat", false, wide);
508
+ const deck = useDeck(`chat:${at}`, SEED);
509
+ useKey(() => setPanel(!panel));
510
+ // On a laptop the frame's column is the panel's; a close from the frame (its
511
+ // edge pulled shut, the shell's own control) comes back into `panel`.
512
+ useEffect(() => {
513
+ if (wide)
514
+ showAside(panel ? "tasks" : null);
515
+ }, [panel, wide, showAside]);
516
+ const wasAside = useRef(aside);
517
+ useEffect(() => {
518
+ if (wide && wasAside.current !== aside) {
519
+ if (aside === null && panel)
520
+ setPanel(false);
521
+ else if (aside !== null && !panel)
522
+ setPanel(true);
523
+ }
524
+ wasAside.current = aside;
525
+ // Answers the frame's moves only.
526
+ // eslint-disable-next-line react-hooks/exhaustive-deps
527
+ }, [aside]);
528
+ /** What an answer wrote, open in the panel as a page rendered from its own bytes. */
529
+ const see = useCallback((a) => {
530
+ deck.open({ kind: PAGE, title: a.name, body: a.body, type: a.mime });
531
+ setPanel(true);
532
+ }, [deck, setPanel]);
515
533
  /**
516
- * OPENS A FILE A MESSAGE CARRIES, by the road the column's Sources row takes
517
- * (`look`): a tab in the browser beside the conversation, the column opened
518
- * first when it is shut. A phone has no column — the frame spends the width
519
- * on the room — so there the file opens in a tab of the browser's own, minted
520
- * before the signed address is asked for, since a window opened after an
521
- * await is a popup the browser blocks.
534
+ * OPENS A FILE A MESSAGE CARRIES, by the road the Sources tab takes (`look`):
535
+ * a page tab in the side panel, the panel opened when it is shut — a column on
536
+ * a laptop, a sheet on a phone.
522
537
  */
523
538
  const openFile = useCallback((m) => {
524
- if (wide) {
525
- if (aside === null)
526
- showAside("tasks");
527
- void look(at, m, ai);
528
- return;
529
- }
530
- const tab = window.open("", "_blank");
531
- if (!tab)
532
- return;
533
- tab.opener = null;
534
- void addressOf(m, ai).then((href) => (href ? tab.location.replace(href) : tab.close()));
535
- }, [wide, aside, showAside, at, ai]);
539
+ void look(deck, m, ai);
540
+ setPanel(true);
541
+ }, [deck, ai, setPanel]);
536
542
  /**
537
543
  * WHO YOU ARE TALKING TO, when it is somebody rather than the plain model.
538
544
  *
@@ -629,20 +635,16 @@ function Thread({ placeholder = message(), initial, anonymous = false, heading,
629
635
  // A SPEECH MODEL NEVER ANSWERS A CHAT TURN. The catalog lists the
630
636
  // transcriber and the voice beside the chat models; one that names its
631
637
  // outputs and names no text (`chats`) is neither offered nor fallen back to.
632
- const { models: catalog } = useModels();
633
- const models = useMemo(() => catalog.filter(chats), [catalog]);
638
+ const listed = mind.models;
639
+ const models = useMemo(() => listed.filter(chats), [listed]);
634
640
  // EVERY MODEL THE GATEWAY LISTS is offered here, through the one picker, and
635
641
  // read from the catalog's own fields (`parseModels`): its class, its family.
636
642
  // A premium model is picked for THIS conversation and never remembered as
637
- // the default, so the next chat starts on Enso again; any other pick is the
638
- // reader's default. A preference kept from before for a premium model is
639
- // not a default either, so the house router is asked for instead. An agent's
640
- // own model is sent as it is named; the gateway says which model answered
641
- // (`servedBy`).
642
- const listed = useMemo(() => parseModels(catalog), [catalog]);
643
+ // the default (`useMind`), so the next chat starts on what was kept. An
644
+ // agent's own model is sent as it is named; the gateway says which model
645
+ // answered (`servedBy`).
643
646
  const rowOf = (id) => listed.find((m) => m.id === id);
644
- const [picked, setPicked] = useState(null);
645
- const wanted = picked ?? (preferred && rowOf(preferred)?.class !== "premium" ? preferred : ENSO);
647
+ const wanted = mind.model;
646
648
  // A model the catalog lists is sent as picked, whatever it answers with; only
647
649
  // a name the catalog does not list falls to one that converses (`served`).
648
650
  const sent = anonymous ? FREE : wanted;
@@ -652,9 +654,11 @@ function Thread({ placeholder = message(), initial, anonymous = false, heading,
652
654
  // bars and no near note — those live on Settings → Usage. It says something
653
655
  // only once the reader is turned away: a reply answered from a free model, or
654
656
  // a refusal.
655
- const { limits, notice, reload: reread } = useLimits(!anonymous, org(), (id) => nameOf(catalog, id));
657
+ const { limits, notice, reload: reread } = useLimits(!anonymous, org(), (id) => nameOf(id, listed));
656
658
  const held = paidPlan(limits);
657
659
  const pause = notice && (notice.fallback || notice.refused) ? notice : null;
660
+ // The banner's words and ways: the plan by name, when it comes back, and the two ways on.
661
+ const banner = pause ? paused(pause, limits?.plan ?? "", (id) => nameOf(id, listed)) : null;
658
662
  // EVERY MODEL IS CHOSEN, never asked for: one the plan does not cover still
659
663
  // picks, and the gateway answers it with the policy's refusal or from Enso,
660
664
  // which the room then shows. A turn the gateway refuses `plan_required`
@@ -662,12 +666,6 @@ function Thread({ placeholder = message(), initial, anonymous = false, heading,
662
666
  // free-lane notice.
663
667
  const free = useFree(!anonymous) && !held;
664
668
  const [ask, setAsk] = useState(null);
665
- const choose = (id) => {
666
- if (rowOf(id)?.class === "premium")
667
- return setPicked(id);
668
- setPicked(null);
669
- setPreferred(id);
670
- };
671
669
  // KEEPING THE CONVERSATION is the reader's account, not this pane's choice:
672
670
  // the store is per-org, so a signed-in reader has somewhere for it to go and
673
671
  // an anonymous one does not. `thread` names the conversation being continued
@@ -684,12 +682,12 @@ function Thread({ placeholder = message(), initial, anonymous = false, heading,
684
682
  // lookups the platform's assistant uses, run inside the completion by the
685
683
  // client's fetch (lib/web.ts), so a question about the weather or the news
686
684
  // is answered from what the web says now.
687
- params: { reasoning_effort: effort, ...(!anonymous && house(rowOf(model)?.family) ? { tools: WEB } : {}) },
685
+ params: { reasoning_effort: mind.effort, ...(!anonymous && house(rowOf(model)?.family) ? { tools: WEB } : {}) },
688
686
  ...(open ? { thread: open } : {}),
689
687
  onError: (e) => {
690
688
  const needs = planRequired(e);
691
689
  if (needs && free)
692
- setAsk({ ...needs, model: nameOf(models, model) });
690
+ setAsk({ ...needs, model: nameOf(model, listed) });
693
691
  },
694
692
  });
695
693
  const { send, set, error, thread: kept } = chat;
@@ -951,7 +949,7 @@ function Thread({ placeholder = message(), initial, anonymous = false, heading,
951
949
  setActWrong("");
952
950
  if (a.kind === "switch") {
953
951
  if (a.model)
954
- choose(a.model);
952
+ mind.pick(a.model);
955
953
  return;
956
954
  }
957
955
  if (a.kind !== "credits") {
@@ -970,9 +968,9 @@ function Thread({ placeholder = message(), initial, anonymous = false, heading,
970
968
  })
971
969
  .catch((e) => setActWrong(e instanceof Error ? e.message : "Credits could not be turned on."));
972
970
  },
973
- // `choose` is rebuilt each render; the handler follows it.
971
+ // `mind.pick` is rebuilt each render; the handler follows it.
974
972
  // eslint-disable-next-line react-hooks/exhaustive-deps
975
- [reread, speak, failures, picked, preferred, listed]);
973
+ [reread, speak, failures, mind.model, listed]);
976
974
  // A TURN THAT NEVER SPEAKS IS STOPPED. A stream that stays open with nothing
977
975
  // in it would leave a Stop button and no answer for as long as the upstream
978
976
  // holds the connection; after `STALL` it is ended and its question is said to
@@ -1005,8 +1003,31 @@ function Thread({ placeholder = message(), initial, anonymous = false, heading,
1005
1003
  if (!heard || (heard.served === heard.asked && ROUTERS.has(heard.asked)))
1006
1004
  return null;
1007
1005
  const fell = heard.asked && heard.asked !== heard.served && !ROUTERS.has(heard.asked);
1008
- return (_jsxs(Text, { fontSize: "$1", color: "$soft", children: ["answered by ", heard.served, fell ? ` · ${heard.asked} was unavailable` : ""] }));
1006
+ return _jsx(Served, { served: heard.served, asked: fell ? heard.asked : undefined });
1007
+ };
1008
+ // LISTEN reads a part in its speaker's cast voice, through the platform.
1009
+ const listen = useCallback((who) => (text, done) => speakAgent(text, { agent: who, token, onEnd: done, onError: done }), [token]);
1010
+ /** The way to open what a part of an answer wrote, where it wrote something. */
1011
+ const opener = (text) => {
1012
+ const a = lead(artifacts(text));
1013
+ return a ? () => see(a) : undefined;
1009
1014
  };
1015
+ // WHAT AN ANSWER MAKES OPENS BESIDE IT. With the panel open, a page or a
1016
+ // picture an answer just finished writing is opened as a tab; with it shut,
1017
+ // the answer's own Open does it.
1018
+ const writing = useRef(false);
1019
+ useEffect(() => {
1020
+ const was = writing.current;
1021
+ writing.current = chat.streaming;
1022
+ if (!was || chat.streaming || !panel)
1023
+ return;
1024
+ const answer = [...turns.current].reverse().find((m) => m.role === "assistant");
1025
+ const made = typeof answer?.content === "string" ? artifacts(answer.content).filter(renders) : [];
1026
+ if (made.length)
1027
+ see(made[made.length - 1]);
1028
+ // On the turn's end only.
1029
+ // eslint-disable-next-line react-hooks/exhaustive-deps
1030
+ }, [chat.streaming]);
1010
1031
  // WHAT WAS SAID, which is not everything the thread holds. The persona rides
1011
1032
  // as a system turn so the wire carries it; drawing it would print a character
1012
1033
  // brief in the transcript as though somebody had typed it, and would make a
@@ -1021,6 +1042,8 @@ function Thread({ placeholder = message(), initial, anonymous = false, heading,
1021
1042
  // record's own picture. Its own answer comes first, which is the order
1022
1043
  // `Face` already states for every other surface.
1023
1044
  const portrait = mate?.avatar ?? persona?.avatar ?? roleOf(withWhom)?.portrait;
1045
+ // Chat's kinds of tab in the side panel: Artifacts and Sources, read from the turns.
1046
+ const kinds = useKinds({ at, said, held: files, deck });
1024
1047
  /** Whether the field has focus, so the FRAME can wear the ring the field is
1025
1048
  * no longer allowed to draw. `:focus-within` would say this in CSS; the
1026
1049
  * stylesheet that would hold it cannot reach past gui's own rule, so the
@@ -1139,17 +1162,17 @@ function Thread({ placeholder = message(), initial, anonymous = false, heading,
1139
1162
  const said = !failed
1140
1163
  ? "No answer came back for this question."
1141
1164
  : failed.stalled
1142
- ? `${nameOf(models, model)} sent nothing for ${STALL / 60_000} minutes, so the turn was stopped.`
1165
+ ? `${nameOf(model, listed)} sent nothing for ${STALL / 60_000} minutes, so the turn was stopped.`
1143
1166
  : failed.empty
1144
- ? `${nameOf(models, model)} answered with nothing.`
1167
+ ? `${nameOf(model, listed)} answered with nothing.`
1145
1168
  : bill
1146
- ? notice?.message ?? (bill.message || plainly(wrong, "this conversation"))
1169
+ ? banner?.message ?? notice?.message ?? (bill.message || plainly(wrong, "this conversation"))
1147
1170
  : resign
1148
1171
  ? anonymous
1149
1172
  ? "Sign in to ask this."
1150
1173
  : "Your session needs to be refreshed. Sign in to reconnect."
1151
1174
  : how?.provider
1152
- ? `${familyOf(model) || nameOf(models, model)} is unavailable right now.`
1175
+ ? `${familyOf(model) || nameOf(model, listed)} is unavailable right now.`
1153
1176
  : plainly(wrong, "this conversation");
1154
1177
  return (_jsx(Failure, { onRetry: resign || bill?.code === "free_plan_cap" ? undefined : () => speak(text, question.id), children: _jsxs(YStack, { gap: "$2", children: [_jsxs(Text, { fontSize: "$3", color: "$ink", children: [whom.length ? `No answer from ${join(whom)}. ` : "", said] }), how?.provider && wrong ? _jsx(Why, { text: detail(wrong) }) : null, !bill && (resign || lift || how?.wallet || how?.visitor) ? (_jsx(XStack, { gap: "$2", flexWrap: "wrap", children: resign ? (_jsx(Button, { size: "sm", onClick: () => {
1155
1178
  if (typeof window !== "undefined") {
@@ -1157,7 +1180,7 @@ function Thread({ placeholder = message(), initial, anonymous = false, heading,
1157
1180
  localStorage.removeItem("hanzo_iam_refresh_token");
1158
1181
  }
1159
1182
  enter(LOGIN);
1160
- }, children: "Sign in" })) : lift ? (_jsx(Button, { size: "sm", variant: "outline", onClick: () => setAsk({ ...lift, model: nameOf(models, model) }), children: "Upgrade" })) : how?.visitor ? (_jsx(Button, { size: "sm", variant: "outline", onClick: () => enter(), children: "Sign in to keep going" })) : (_jsx(Anchor, { href: checkoutUrl(), children: _jsx(Button, { size: "sm", variant: "outline", children: "Add credit" }) })) })) : null] }) }));
1183
+ }, children: "Sign in" })) : lift ? (_jsx(Button, { size: "sm", variant: "outline", onClick: () => setAsk({ ...lift, model: nameOf(model, listed) }), children: "Upgrade" })) : how?.visitor ? (_jsx(Button, { size: "sm", variant: "outline", onClick: () => enter(), children: "Sign in to keep going" })) : (_jsx(Anchor, { href: checkoutUrl(), children: _jsx(Button, { size: "sm", variant: "outline", children: "Add credit" }) })) })) : null] }) }));
1161
1184
  };
1162
1185
  // The agreement is asked OVER the room, not instead of it. Returning the
1163
1186
  // consent on its own replaced the whole pane: the question the reader had
@@ -1203,22 +1226,7 @@ function Thread({ placeholder = message(), initial, anonymous = false, heading,
1203
1226
  e.preventDefault();
1204
1227
  // A dropped folder arrives as entries; each file under it goes up with its path.
1205
1228
  void dropped(e.dataTransfer).then((list) => hold(at, list, via));
1206
- }, children: [dragging ? (_jsx(YStack, { "aria-hidden": true, position: "absolute", t: 0, r: 0, b: 0, l: 0, z: 10, items: "center", justify: "center", bg: "var(--surface-scrim)", borderWidth: 2, borderStyle: "dashed", borderColor: "$borderColor", rounded: "var(--pane-round)", pointerEvents: "none", children: _jsxs(XStack, { items: "center", gap: "$2", px: "$4", py: "$2.5", rounded: "$10", bg: "$background", borderWidth: 1, borderColor: "$borderColor", children: [_jsx(Paperclip, { size: 15, "aria-hidden": true }), _jsx(Text, { fontSize: "$4", color: "$ink", children: "Drop files to attach" })] }) })) : null, _jsxs(XStack, { height: 48, items: "center", justify: "space-between", px: "$3", borderBottomWidth: 1, borderColor: "$borderColor", children: [_jsxs(XStack, { items: "center", gap: "$2", minW: 0, children: [_jsx(Box, { render: "button", onClick: () => mine && void acts.pin(mine), "aria-disabled": !mine, "aria-label": mine?.pinned ? "Unpin this conversation" : "Pin this conversation", "aria-pressed": !!mine?.pinned, p: "$1.5", rounded: "$2", opacity: mine ? 1 : 0.5, hoverStyle: { bg: '$hover' }, children: _jsx(Pin, { size: 15, "aria-hidden": true, color: mine?.pinned ? "var(--foreground)" : "var(--muted-foreground)", fill: mine?.pinned ? "currentColor" : "none" }) }), room.length ? (_jsx(XStack, { role: "list", "aria-label": "Agents in this chat", items: "center", gap: "$1.5", minW: 0, overflowX: "auto", "$platform-web": { scrollbarWidth: "none" }, children: room.map((name) => (_jsxs(XStack, { role: "listitem", items: "center", gap: "$1.5", pl: "$1", pr: "$0.5", py: "$0.5", rounded: 999, borderWidth: 1, borderColor: "$borderColor", shrink: 0, children: [_jsx(Face, { src: faces[name]?.avatar ?? (name === withWhom ? portrait : undefined), emoji: faces[name]?.emoji ?? (name === withWhom ? persona?.emoji : undefined), name: name, size: 22 }), _jsx(Text, { fontSize: "$2", fontWeight: "600", color: "$color", numberOfLines: 1, display: "none", $sm: { display: "flex" }, children: name }), _jsx(Box, { render: "button", onClick: () => openAgent(name), "aria-label": `Remove ${name} from this chat`, title: `Remove ${name}`, width: 24, height: 24, rounded: 999, items: "center", justify: "center", hoverStyle: { bg: "$hover" }, children: _jsx(X, { size: 13, "aria-hidden": true, color: "var(--soft, var(--muted-foreground))" }) })] }, name))) })) : null] }), _jsxs(XStack, { items: "center", gap: "$1", children: [_jsx(Box, { render: "button", onClick: () => {
1207
- if (aside === null) {
1208
- showAside("tasks");
1209
- if (!pinned)
1210
- flipPin();
1211
- return;
1212
- }
1213
- flipPin();
1214
- }, "aria-label": "Toggle pinned summary", "aria-pressed": aside !== null && pinned,
1215
- // NOT ON A PHONE, because there is nothing for it to show there.
1216
- // The frame draws the column only above `$lg` — it spends the whole
1217
- // width on the room below that (`Shell.tsx`) — so this control
1218
- // would press, change the stored pin, and move nothing on screen.
1219
- // A button whose effect is invisible is a button that does nothing.
1220
- // Same breakpoint as the column, so the two cannot drift apart.
1221
- display: "none", $lg: { display: "flex" }, p: "$2", rounded: "$2", hoverStyle: { bg: "$hover" }, children: _jsx(PanelRight, { size: 15, color: "var(--soft, var(--muted-foreground))" }) }), open ? _jsx(Share, { thread: open }) : null] })] }), _jsxs(Beside, { children: [_jsx(RightPane, { said: said, onClose: () => showAside(null) }), _jsx(Work, { persona: persona ?? null, room: room, model: model, messages: said })] }), isFree(model) ? (_jsxs(Text, { fontSize: "$2", color: "$soft", text: "center", maxW: MEASURE, mx: "auto", px: "$4", py: "$2", children: [freeCopy.notice, " ", _jsx(Anchor, { href: site("/legal/privacy"), children: freeCopy.noticeCta })] })) : null, _jsx(ChatSurface, { ...chat, messages: shown, send: guarded,
1229
+ }, children: [dragging ? (_jsx(YStack, { "aria-hidden": true, position: "absolute", t: 0, r: 0, b: 0, l: 0, z: 10, items: "center", justify: "center", bg: "var(--surface-scrim)", borderWidth: 2, borderStyle: "dashed", borderColor: "$borderColor", rounded: "var(--pane-round)", pointerEvents: "none", children: _jsxs(XStack, { items: "center", gap: "$2", px: "$4", py: "$2.5", rounded: "$10", bg: "$background", borderWidth: 1, borderColor: "$borderColor", children: [_jsx(Paperclip, { size: 15, "aria-hidden": true }), _jsx(Text, { fontSize: "$4", color: "$ink", children: "Drop files to attach" })] }) })) : null, _jsxs(XStack, { height: 48, items: "center", justify: "space-between", px: "$3", borderBottomWidth: 1, borderColor: "$borderColor", children: [_jsxs(XStack, { items: "center", gap: "$2", minW: 0, children: [_jsx(Box, { render: "button", onClick: () => mine && void acts.pin(mine), "aria-disabled": !mine, "aria-label": mine?.pinned ? "Unpin this conversation" : "Pin this conversation", "aria-pressed": !!mine?.pinned, p: "$1.5", rounded: "$2", opacity: mine ? 1 : 0.5, hoverStyle: { bg: '$hover' }, children: _jsx(Pin, { size: 15, "aria-hidden": true, color: mine?.pinned ? "var(--foreground)" : "var(--muted-foreground)", fill: mine?.pinned ? "currentColor" : "none" }) }), room.length ? (_jsx(XStack, { role: "list", "aria-label": "Agents in this chat", items: "center", gap: "$1.5", minW: 0, overflowX: "auto", "$platform-web": { scrollbarWidth: "none" }, children: room.map((name) => (_jsxs(XStack, { role: "listitem", items: "center", gap: "$1.5", pl: "$1", pr: "$0.5", py: "$0.5", rounded: 999, borderWidth: 1, borderColor: "$borderColor", shrink: 0, children: [_jsx(Face, { src: faces[name]?.avatar ?? (name === withWhom ? portrait : undefined), emoji: faces[name]?.emoji ?? (name === withWhom ? persona?.emoji : undefined), name: name, size: 22 }), _jsx(Text, { fontSize: "$2", fontWeight: "600", color: "$color", numberOfLines: 1, display: "none", $sm: { display: "flex" }, children: name }), _jsx(Box, { render: "button", onClick: () => openAgent(name), "aria-label": `Remove ${name} from this chat`, title: `Remove ${name}`, width: 24, height: 24, rounded: 999, items: "center", justify: "center", hoverStyle: { bg: "$hover" }, children: _jsx(X, { size: 13, "aria-hidden": true, color: "var(--soft, var(--muted-foreground))" }) })] }, name))) })) : null] }), _jsxs(XStack, { items: "center", gap: "$2", children: [open ? _jsx(Share, { thread: open }) : null, _jsx(PanelToggle, { open: panel, onToggle: () => setPanel(!panel) })] })] }), wide && aside !== null ? (_jsx(Beside, { children: _jsx(Panel, { side: deck, kinds: kinds, onHide: () => setPanel(false), label: "Conversation details" }) })) : null, !wide && panel ? _jsx(Panel, { side: deck, kinds: kinds, sheet: true, onHide: () => setPanel(false), label: "Conversation details" }) : null, isFree(model) ? (_jsxs(Text, { fontSize: "$2", color: "$soft", text: "center", maxW: MEASURE, mx: "auto", px: "$4", py: "$2", children: [freeCopy.notice, " ", _jsx(Anchor, { href: site("/legal/privacy"), children: freeCopy.noticeCta })] })) : null, _jsx(ChatSurface, { ...chat, messages: shown, send: guarded,
1222
1230
  // AN EMPTY ROOM CENTRES ITS COMPOSER. While nothing has been said the
1223
1231
  // opening (the heading and the starters) and the composer sit as one
1224
1232
  // group in the middle of the height, with or without the plan bars in
@@ -1270,9 +1278,9 @@ function Thread({ placeholder = message(), initial, anonymous = false, heading,
1270
1278
  const silent = room.length > 1 && !(chat.streaming && at === shown.length - 1)
1271
1279
  ? addressed(words(q?.content), room).filter((name) => !parts.some((part) => part.who?.toLowerCase() === name.toLowerCase()))
1272
1280
  : [];
1273
- return (_jsxs(YStack, { gap: "$3", children: [parts.map((part, i) => (_jsxs(YStack, { gap: "$1", children: [part.who ? (_jsxs(XStack, { items: "center", gap: "$2", children: [_jsx(Face, { src: faces[part.who]?.avatar, emoji: faces[part.who]?.emoji, name: part.who, size: 20 }), _jsx(Text, { fontSize: "$2", fontWeight: "600", color: "$ink", children: part.who })] })) : null, _jsx(Prose, { text: part.text }), part.text ? (_jsxs(XStack, { ml: "$-1", items: "center", gap: "$2", children: [_jsx(Take, { text: part.text, says: "this answer" }), _jsx(SpeakButton, { text: part.text, agentName: part.who ?? withWhom ?? undefined })] })) : null] }, i))), silent.length ? (_jsxs(Text, { fontSize: "$2", color: "$soft", children: ["No answer from ", join(silent), " in this reply."] })) : null, byline(turn.id)] }));
1281
+ return (_jsxs(YStack, { gap: "$3", children: [parts.map((part, i) => (_jsxs(YStack, { gap: "$1", children: [part.who ? (_jsxs(XStack, { items: "center", gap: "$2", children: [_jsx(Face, { src: faces[part.who]?.avatar, emoji: faces[part.who]?.emoji, name: part.who, size: 20 }), _jsx(Text, { fontSize: "$2", fontWeight: "600", color: "$ink", children: part.who })] })) : null, _jsx(Prose, { text: part.text }), part.text ? _jsx(Reply, { text: part.text, listen: listen(part.who ?? withWhom ?? undefined), onOpen: opener(part.text) }) : null] }, i))), silent.length ? (_jsxs(Text, { fontSize: "$2", color: "$soft", children: ["No answer from ", join(silent), " in this reply."] })) : null, byline(turn.id)] }));
1274
1282
  }
1275
- return (_jsxs(YStack, { gap: "$1", children: [_jsx(Prose, { text: said }), turn.role === "assistant" && said ? (_jsxs(XStack, { ml: "$-1", items: "center", gap: "$2", children: [_jsx(Take, { text: said, says: "this answer" }), _jsx(SpeakButton, { text: said, agentName: withWhom })] })) : null, turn.role === "assistant" && said ? byline(turn.id) : null] }));
1283
+ return (_jsxs(YStack, { gap: "$1", children: [_jsx(Prose, { text: said }), turn.role === "assistant" && said ? _jsx(Reply, { text: said, listen: listen(withWhom ?? undefined), onOpen: opener(said) }) : null, turn.role === "assistant" && said ? byline(turn.id) : null] }));
1276
1284
  },
1277
1285
  // The page's ONE heading, and it belongs to the empty transcript rather
1278
1286
  // than to a signed-out branch — the branch is gone, and the heading went
@@ -1284,61 +1292,20 @@ function Thread({ placeholder = message(), initial, anonymous = false, heading,
1284
1292
  }, px: "$3", py: "$2", rounded: "$4", borderWidth: 1, borderColor: "$borderColor", hoverStyle: { bg: "$raised" }, children: _jsx(Text, { fontSize: "$2", color: "$soft", children: one }) }, one))) })] })),
1285
1293
  // The transcript reads at the same measure. `Thread` defaults to 768
1286
1294
  // itself; handing it the column here is what lets Width reach it.
1287
- column: { maxW: MEASURE }, composer: {
1295
+ column: { maxW: MEASURE }, composer: prompt({
1288
1296
  value: draft,
1289
1297
  onChange: setDraft,
1290
1298
  onSend: submit,
1291
- // A CHARACTER IS WHO YOU ARE WRITING TO, so the field says so — and
1292
- // `label` keeps the accessible name constant while it does, which is
1293
- // the reason the component ships both: a placeholder that changes with
1294
- // the room would otherwise rename the control every time you switch.
1299
+ onStop: chat.stop,
1300
+ busy: chat.streaming,
1301
+ // Files alone are a message: what is held goes with an empty draft too.
1302
+ ready: Boolean(draft.trim() || files.length),
1303
+ // A CHARACTER IS WHO YOU ARE WRITING TO, so the field says so, and
1304
+ // `label` keeps the accessible name constant while it does.
1295
1305
  placeholder: room.length ? `Message ${join(room)}` : placeholder,
1296
1306
  label: ASK,
1297
- // CENTRED WHILE NOTHING HAS BEEN SAID, docked once something has —
1298
- // `center` on the surface, which spaces the pair from outside it,
1299
- // because with a foot the composer is a shell around this frame and
1300
- // a margin given here would land inside the shell.
1301
- // THE SAME MEASURE THE REST OF THIS FILE READS AT. The transcript's
1302
- // text sits at the measure less its own 0.75rem gutters, so the field
1303
- // is that wide too and its edges line up with the words above it.
1304
- width: "calc(100% - 32px)",
1305
- maxW: `calc(${MEASURE} - 1.5rem)`,
1306
- mx: "auto",
1307
- ...(framed ? pane() : null),
1308
- // THE COMPONENT'S OWN SHAPE, and nothing here reaching into it.
1309
- //
1310
- // This carried about twenty style props that rebuilt it into a single
1311
- // capsule: `flexDirection: "row"` above all, which lays the footer row
1312
- // BESIDE a `flex: 1` field instead of under it. The footer row has no
1313
- // width of its own in that arrangement, so send left the viewport at
1314
- // every size but the widest — measured at 1280 it ended at 1315, at
1315
- // 834 at 902, at 390 at 466, and on a phone the field stood 200px tall
1316
- // where a line is 24. Each override was answering damage from the one
1317
- // before it.
1318
- //
1319
- // Composer is a field over a footer row. CHOOSING a model is a
1320
- // preference and not part of every message, so Settings owns the
1321
- // choice — but the row still NAMES what is about to answer, because
1322
- // `served()` may not send what was asked for.
1323
- //
1324
- // That fallback is written against this: a preference the gateway
1325
- // does not carry gives way to a model it does, "because a chat that
1326
- // works on a model the reader can see beats a chat that refuses on a
1327
- // name nobody serves". Measured on production, neither `enso` nor
1328
- // `free` is in the catalogue — every turn lands on the first served
1329
- // model — so without this the substitution happens in silence and the
1330
- // sentence justifying it is not true.
1331
- //
1332
- // Pressing it opens the pane that owns the choice, which is the one
1333
- // place to change it.
1334
- // Everything that is not the field, at the end of the row: Enso, the
1335
- // mic, send. Send is drawn here because this slot replaces the
1336
- // package's button and `streaming` still has to become stop; it keeps
1337
- // `data-slot="composer-send"` so a driver finds it, and it is the same
1338
- // `Control` circle as the mic, filled.
1339
- // THE PAPERCLIP is the column's "Attach files", one press closer: the
1340
- // same `take`, into the same `hold`.
1341
- children: (_jsxs(Tooltip, { children: [_jsx(TooltipTrigger, { children: _jsx(Control, { type: "button", size: ROUND, "aria-label": "Attach files", onClick: () => take(at, via), children: _jsx(Paperclip, { size: 15, "aria-hidden": true }) }) }), _jsx(TooltipContent, { children: _jsx(Text, { fontSize: "$2", children: "Attach files" }) })] })),
1307
+ // In the app's frame the composer floats on the pane as a pane of its own.
1308
+ framed,
1342
1309
  // A file or an image pasted into the field is held like a picked one;
1343
1310
  // words paste as words.
1344
1311
  field: {
@@ -1349,70 +1316,26 @@ function Thread({ placeholder = message(), initial, anonymous = false, heading,
1349
1316
  void hold(at, Array.from(e.clipboardData.files), via);
1350
1317
  },
1351
1318
  },
1352
- // WHAT GOES WITH THE MESSAGE, above the frame: one chip a file, each
1353
- // with its own remove, and the reason any file was refused.
1354
- head: files.length || turnedAway || preparing || talk.open || talk.refusal || pause ? (_jsxs(YStack, { width: "calc(100% - 32px)", maxW: `calc(${MEASURE} - 1.5rem)`, mx: "auto", gap: "$1.5", children: [pause ? (_jsx(LimitedBanner, { message: pause.message, actions: pause.actions, onAction: act, onUsage: () => showSettings("usage") })) : null, actWrong ? (_jsx(Text, { role: "alert", "data-slot": "limited-banner-wrong", fontSize: "$2", color: BAD, children: actWrong })) : null, files.length ? (_jsx(XStack, { gap: "$1.5", flexWrap: "wrap", role: "list", "aria-label": "Attached files", "data-slot": "composer-attached", children: files.map((one) => (_jsx(HeldChip, { one: one, onOpen: openFile, onRemove: () => drop(at, one.id) }, one.id))) })) : null, preparing ? (_jsxs(Text, { role: "status", "aria-live": "polite", "data-slot": "composer-preparing", fontSize: "$2", color: "$soft", children: [preparing, "\u2026"] })) : null, turnedAway ? (_jsx(Text, { role: "alert", fontSize: "$2", color: BAD, children: turnedAway })) : null, talk.open || talk.refusal ? (_jsx(Text, { role: "status", "aria-live": "polite", "data-slot": "composer-talk-reply", fontSize: "$2", color: talk.refusal ? BAD : "$soft", children: talk.refusal
1319
+ // WHAT GOES WITH THE MESSAGE, above the frame and at its width: the
1320
+ // pause and its ways past, one chip a file, and talk mode's words.
1321
+ head: files.length || turnedAway || preparing || talk.open || talk.refusal || pause ? (_jsxs(_Fragment, { children: [banner ? (_jsx(LimitedBanner, { message: banner.message, actions: banner.actions, onAction: act, onUsage: () => showSettings("usage") })) : null, actWrong ? (_jsx(Text, { role: "alert", "data-slot": "limited-banner-wrong", fontSize: "$2", color: BAD, children: actWrong })) : null, files.length ? (_jsx(XStack, { gap: "$1.5", flexWrap: "wrap", role: "list", "aria-label": "Attached files", "data-slot": "composer-attached", children: files.map((one) => (_jsx(HeldChip, { one: one, onOpen: openFile, onRemove: () => drop(at, one.id) }, one.id))) })) : null, preparing ? (_jsxs(Text, { role: "status", "aria-live": "polite", "data-slot": "composer-preparing", fontSize: "$2", color: "$soft", children: [preparing, "\u2026"] })) : null, turnedAway ? (_jsx(Text, { role: "alert", fontSize: "$2", color: BAD, children: turnedAway })) : null, talk.open || talk.refusal ? (_jsx(Text, { role: "status", "aria-live": "polite", "data-slot": "composer-talk-reply", fontSize: "$2", color: talk.refusal ? BAD : "$soft", children: talk.refusal
1355
1322
  ? worded(talk.refusal)
1356
1323
  : talk.reply
1357
1324
  ? `Hanzo: ${talk.reply}`
1358
1325
  : talk.state === "listening"
1359
1326
  ? "Listening — speak, and Hanzo answers aloud."
1360
1327
  : "Connecting…" })) : null] })) : undefined,
1361
- send: (_jsxs(XStack, { items: "center", gap: "$2", children: [_jsx(Enso, { effort: effort, onEffort: setEffort, model: wanted, onModel: choose, models: listed, limits: limits }), _jsx(Control, { asChild: true, size: ROUND, children: _jsx(Voice, { voice: voice, says: { idle: "Dictate", listening: "Dictating — click to stop" }, children: (state) => (_jsx(Mic, { size: 15, fill: state === "idle" ? "none" : "currentColor" })) }) }), _jsx(Control, { asChild: true, size: ROUND, children: _jsx(Voice, { voice: talk, "data-slot": "composer-talk", says: {
1362
- idle: "Talk with Hanzo",
1363
- listening: "In conversation — click to hang up",
1364
- speaking: "Hanzo is speaking — talk to interrupt",
1365
- }, children: () => _jsx(AudioLines, { size: 15 }) }) }), _jsx(Control, { type: "button", size: ROUND, fill: true, "data-slot": "composer-send", "aria-label": chat.streaming ? "Stop" : "Send", opacity: chat.streaming || draft.trim() || files.length ? 1 : 0.4, onClick: () => (chat.streaming ? chat.stop() : submit()), children: chat.streaming ? (_jsx(Square, { size: 13, fill: "currentColor" })) : (_jsx(ArrowUp, { size: 15, strokeWidth: 2.5 })) })] })),
1366
- } }), said.length === 0 ? _jsx(Crew, {}) : null, isFree(model) ? (_jsx(XStack, { items: "center", justify: "center", py: "$1", px: "$3", borderTopWidth: 1, borderColor: "$borderColor", bg: "$background", children: _jsxs(Text, { fontSize: "$1", color: "$soft", children: ["Free chat is data-shared to train models. ", _jsx(Anchor, { href: site("/privacy"), fontSize: "$1", color: "$ink", children: "Privacy" })] }) })) : null, consent, _jsx(Upgrade, { ask: ask, onClose: () => setAsk(null) })] }));
1328
+ // THE PAPERCLIP is the Sources tab's "Attach files", one press closer:
1329
+ // the same `take`, into the same `hold`.
1330
+ onAttach: () => take(at, via),
1331
+ // The one model-and-effort chip Dev's composer has (@hanzo/build).
1332
+ mind,
1333
+ limits,
1334
+ voice,
1335
+ talk,
1336
+ }) }), said.length === 0 ? _jsx(Crew, {}) : null, isFree(model) ? (_jsx(XStack, { items: "center", justify: "center", py: "$1", px: "$3", borderTopWidth: 1, borderColor: "$borderColor", bg: "$background", children: _jsxs(Text, { fontSize: "$1", color: "$soft", children: ["Free chat is data-shared to train models. ", _jsx(Anchor, { href: site("/privacy"), fontSize: "$1", color: "$ink", children: "Privacy" })] }) })) : null, consent, _jsx(Upgrade, { ask: ask, onClose: () => setAsk(null) })] }));
1367
1337
  }
1368
1338
  /** The agreement free asks for, in @hanzo/ai's words so every surface says one thing. */
1369
1339
  function Consent({ question, onAgree, onCancel, }) {
1370
1340
  return (_jsx(YStack, { position: "absolute", t: 0, l: 0, r: 0, b: 0, items: "center", justify: "center", p: "$4", z: 40, bg: SCRIM, children: _jsxs(YStack, { width: "100%", maxW: 512, px: "$5", py: "$5", gap: "$4", rounded: "$4", borderWidth: 1, borderColor: "$borderColor", bg: "$background", children: [_jsx(Text, { render: "h2", fontSize: "$6", fontWeight: "500", color: "$ink", children: freeCopy.consentTitle }), question ? (_jsxs(Text, { fontSize: "$3", color: "$soft", numberOfLines: 2, children: ["Your question is waiting: \u201C", question, "\u201D"] })) : null, _jsx(Text, { fontSize: "$4", color: "$soft", children: freeCopy.consentBody }), _jsx(YStack, { gap: "$2", children: freeCopy.consentPoints.map((point) => (_jsx(Text, { fontSize: "$3", color: "$soft", children: point }, point))) }), _jsxs(XStack, { gap: "$3", flexWrap: "wrap", children: [_jsx(Button, { onClick: onAgree, children: freeCopy.consentAgreeCta }), _jsx(Button, { variant: "outline", onClick: onCancel, children: freeCopy.consentCancelCta })] }), _jsxs(Text, { fontSize: "$2", color: "$soft", children: [freeCopy.termsText, " ", _jsx(Anchor, { href: site("/legal/terms"), children: "Terms" }), " · ", _jsx(Anchor, { href: site("/legal/privacy"), children: "Privacy" })] })] }) }));
1371
1341
  }
1372
- function Work({ persona, room, model, messages, }) {
1373
- const done = useMemo(() => {
1374
- // A result arrives as its own turn, keyed back to the call that asked for
1375
- // it — so the pairing is a lookup and not an assumption about order.
1376
- const back = new Map();
1377
- for (const m of messages) {
1378
- if (m.role === "tool" && m.tool_call_id) {
1379
- back.set(m.tool_call_id, typeof m.content === "string" ? m.content : "");
1380
- }
1381
- }
1382
- return messages
1383
- .flatMap((m) => m.tool_calls ?? [])
1384
- .map((call) => ({
1385
- id: call.id,
1386
- name: call.function.name,
1387
- asked: call.function.arguments,
1388
- got: back.get(call.id) ?? null,
1389
- }));
1390
- }, [messages]);
1391
- /**
1392
- * WHAT THE CONVERSATION MADE.
1393
- *
1394
- * A fenced block in an answer is a thing rather than a sentence — a file, a
1395
- * command, a page — and read inline it scrolls away with the talk. Here it
1396
- * keeps a label and a copy control, which is what somebody came to a code
1397
- * block to do.
1398
- *
1399
- * Read from the turns rather than tracked as it arrives: the transcript is
1400
- * already the record, and a second list of what was made would be a second
1401
- * thing to keep in step with it.
1402
- */
1403
- const made = useMemo(() => {
1404
- const out = [];
1405
- for (const m of messages) {
1406
- if (m.role !== "assistant")
1407
- continue;
1408
- const text = typeof m.content === "string" ? m.content : "";
1409
- for (const block of text.matchAll(/```([\w.+-]*)\n([\s\S]*?)```/g)) {
1410
- const code = block[2].replace(/\s+$/, "");
1411
- if (code)
1412
- out.push({ id: `${m.id}-${out.length}`, language: block[1] || "text", code });
1413
- }
1414
- }
1415
- return out;
1416
- }, [messages]);
1417
- return (_jsxs(YStack, { p: "$3", gap: "$4", children: [_jsxs(YStack, { gap: "$1", children: [_jsx(Text, { fontSize: "$1", color: "$soft", textTransform: "uppercase", children: "Answering" }), room.length ? (room.map((name) => (_jsxs(XStack, { items: "center", gap: "$2", children: [_jsx(Face, { name: name, size: 22 }), _jsx(Text, { fontSize: "$2", color: "$ink", numberOfLines: 1, children: name })] }, name)))) : (_jsx(Text, { fontSize: "$2", color: "$ink", numberOfLines: 1, children: model })), room.length === 1 && persona ? (_jsx(Text, { fontSize: "$1", color: "$soft", numberOfLines: 3, children: persona.description || model })) : null] }), made.length > 0 ? (_jsxs(YStack, { gap: "$2", children: [_jsx(Text, { fontSize: "$1", color: "$soft", textTransform: "uppercase", children: "Artifacts" }), made.map((one) => (_jsx(Code, { language: one.language, value: one.code, children: one.code }, one.id)))] })) : null, _jsxs(YStack, { gap: "$2", children: [_jsx(Text, { fontSize: "$1", color: "$soft", textTransform: "uppercase", children: "Work" }), done.length === 0 ? (_jsx(Text, { fontSize: "$2", color: "$soft", children: "Nothing was looked up for this conversation yet." })) : (done.map((call) => (_jsxs(YStack, { gap: "$1", p: "$2", rounded: "$3", borderWidth: 1, borderColor: "$borderColor", children: [_jsx(Text, { fontSize: "$2", color: "$ink", children: call.name }), _jsx(Text, { fontSize: "$1", color: "$soft", numberOfLines: 2, children: call.asked }), _jsx(Text, { fontSize: "$1", color: "$soft", numberOfLines: 6, children: call.got ?? "Running…" })] }, call.id))))] })] }));
1418
- }