@oharness/server 0.8.0 → 0.9.1

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.
Files changed (43) hide show
  1. package/dist/account-usage.d.ts +29 -0
  2. package/dist/account-usage.js +56 -0
  3. package/dist/account-usage.js.map +1 -0
  4. package/dist/files.d.ts +40 -1
  5. package/dist/files.js +94 -3
  6. package/dist/files.js.map +1 -1
  7. package/dist/i18n.js +126 -2
  8. package/dist/i18n.js.map +1 -1
  9. package/dist/page/client/base.js +4 -0
  10. package/dist/page/client/base.js.map +1 -1
  11. package/dist/page/client/browser.js +3 -1
  12. package/dist/page/client/browser.js.map +1 -1
  13. package/dist/page/client/composer.js +20 -0
  14. package/dist/page/client/composer.js.map +1 -1
  15. package/dist/page/client/files.d.ts +7 -0
  16. package/dist/page/client/files.js +412 -0
  17. package/dist/page/client/files.js.map +1 -0
  18. package/dist/page/client/index.js +4 -0
  19. package/dist/page/client/index.js.map +1 -1
  20. package/dist/page/client/panels.js +1 -1
  21. package/dist/page/client/routines.js +19 -1
  22. package/dist/page/client/routines.js.map +1 -1
  23. package/dist/page/client/shell.js +12 -1
  24. package/dist/page/client/shell.js.map +1 -1
  25. package/dist/page/client/state.js +12 -4
  26. package/dist/page/client/state.js.map +1 -1
  27. package/dist/page/client/terminal.d.ts +7 -0
  28. package/dist/page/client/terminal.js +347 -0
  29. package/dist/page/client/terminal.js.map +1 -0
  30. package/dist/page/client/transcript.js +167 -0
  31. package/dist/page/client/transcript.js.map +1 -1
  32. package/dist/page/client/usage.js +400 -70
  33. package/dist/page/client/usage.js.map +1 -1
  34. package/dist/page/style.js +258 -3
  35. package/dist/page/style.js.map +1 -1
  36. package/dist/page.js +35 -1
  37. package/dist/page.js.map +1 -1
  38. package/dist/server.js +238 -31
  39. package/dist/server.js.map +1 -1
  40. package/dist/terminal.d.ts +34 -0
  41. package/dist/terminal.js +211 -0
  42. package/dist/terminal.js.map +1 -0
  43. package/package.json +9 -8
@@ -597,7 +597,11 @@ function dateRangePicker(options) {
597
597
  * The models drawn are the ones with a share worth a band; the rest are
598
598
  * summed into one so the columns still add up to the total above them.
599
599
  */
600
- function usageChart(body, models) {
600
+ function usageChart(body, models, opts) {
601
+ // How a column is read out, and what its date is: dollars by the day for
602
+ // this machine's transcripts, credits by the day or hour for the account.
603
+ const money = (opts && opts.money) || ((value) => "$" + value.toFixed(2));
604
+ const when = (opts && opts.when) || ((at) => new Date(at).toLocaleDateString());
601
605
  const series = body.series || {};
602
606
  const buckets = series.buckets || [];
603
607
  const card = el("div", "row-card chart-card");
@@ -683,9 +687,10 @@ function usageChart(body, models) {
683
687
  // ends of the window. Rendered as text beside the plot rather than as axis
684
688
  // ticks, which at this size would be four numbers fighting for two pixels.
685
689
  const scale = el("div", "chart-scale");
686
- const reading = el("span", null, "$" + peak.toFixed(2));
690
+ const reading = el("span", null, money(peak));
687
691
  scale.appendChild(reading);
688
- scale.appendChild(el("span", "muted", t(series.step === "week" ? "usage.perWeek" : "usage.perDay")));
692
+ scale.appendChild(el("span", "muted",
693
+ t(series.step === "week" ? "usage.perWeek" : series.step === "hour" ? "usage.perHour" : "usage.perDay")));
689
694
  card.appendChild(scale);
690
695
 
691
696
  /*
@@ -710,21 +715,24 @@ function usageChart(body, models) {
710
715
  const top = lines
711
716
  .map((line) => ({ model: line.model, cost: line.points[at] || 0 }))
712
717
  .sort((a, b) => b.cost - a.cost)[0];
713
- const on = new Date(bucket.at).toLocaleDateString();
718
+ const on = when(bucket.at);
714
719
  reading.textContent = bucket.costUsd
715
- ? on + " · $" + bucket.costUsd.toFixed(2) + (top && top.cost ? " · " + top.model : "")
720
+ ? on + " · " + money(bucket.costUsd) + (top && top.cost ? " · " + top.model : "")
716
721
  : on + " · " + t("usage.nothing");
717
722
  });
718
723
  svg.addEventListener("mouseleave", () => {
719
724
  highlight.setAttribute("opacity", "0");
720
- reading.textContent = "$" + peak.toFixed(2);
725
+ reading.textContent = money(peak);
721
726
  });
722
727
 
723
728
  const span = el("div", "chart-span muted small");
724
- span.appendChild(el("span", null, new Date(buckets[0].at).toLocaleDateString()));
725
- span.appendChild(el("span", null, new Date(buckets[buckets.length - 1].at).toLocaleDateString()));
729
+ span.appendChild(el("span", null, when(buckets[0].at)));
730
+ span.appendChild(el("span", null, when(buckets[buckets.length - 1].at)));
726
731
  card.appendChild(span);
727
732
 
733
+ // One band is its own caption: the model is already named in the list
734
+ // under the chart and in the readout above, and a legend of one is noise.
735
+ if (lines.length < 2) return card;
728
736
  const legend = el("div", "chart-legend");
729
737
  lines.forEach((line, index) => {
730
738
  const entry = el("span", "legend-item");
@@ -736,18 +744,34 @@ function usageChart(body, models) {
736
744
  return card;
737
745
  }
738
746
 
739
- async function showUsage() {
740
- // The days ride along only when they are the window being asked for; the
741
- // server ignores them otherwise, and a preset request that carried a stale
742
- // pair would be one keystroke from answering for the wrong fortnight.
743
- const asked = usageRange === "custom" && usageFrom && usageTo
744
- ? "/usage?range=custom&from=" + encodeURIComponent(usageFrom) + "&to=" + encodeURIComponent(usageTo)
745
- : "/usage?range=" + encodeURIComponent(usageRange);
746
- const body = await getJson(asked);
747
- panelBody.replaceChildren();
748
- if (body.error) return void panelBody.appendChild(panelError(body.error));
747
+ /**
748
+ * Whose figures the page shows: the account's ledger, or this machine's
749
+ * transcripts. Null until someone picks, which means the account when there
750
+ * is one — that is where every charge is, and this machine's transcripts are
751
+ * only the chat that happened on it.
752
+ */
753
+ let usageSource = null;
754
+ /** Whether the last look found an account, so this machine's view can offer the other. */
755
+ let usageSignedIn = false;
756
+ /** Which kind of spend the account view is narrowed to. */
757
+ let usageKind = "all";
758
+ const USAGE_KINDS = ["all", "text", "image", "video", "audio"];
749
759
 
750
- const panel = el("div", "panel usage");
760
+ /**
761
+ * The window being asked about, as a query on path.
762
+ *
763
+ * The days ride along only when they are the window being asked for; the
764
+ * server ignores them otherwise, and a preset request that carried a stale
765
+ * pair would be one keystroke from answering for the wrong fortnight.
766
+ */
767
+ function usageQuery(path) {
768
+ return usageRange === "custom" && usageFrom && usageTo
769
+ ? path + "?range=custom&from=" + encodeURIComponent(usageFrom) + "&to=" + encodeURIComponent(usageTo)
770
+ : path + "?range=" + encodeURIComponent(usageRange);
771
+ }
772
+
773
+ /** The page's heading, its way back, and — for an account — whose figures these are. */
774
+ function usageHead(panel) {
751
775
  const head = el("div", "row-head");
752
776
  head.appendChild(el("h2", null, t("usage.title")));
753
777
  // Back to where this was opened from, because it was opened from a row in
@@ -759,29 +783,45 @@ async function showUsage() {
759
783
  setNav("nav-settings");
760
784
  void showSettings();
761
785
  });
762
- head.appendChild(back);
763
- panel.appendChild(head);
764
-
765
786
  /*
766
- * How far back the page is asking about.
787
+ * Whose figures these are, as a small switch in the heading rather than a
788
+ * third row of tabs.
767
789
  *
768
- * A segmented control rather than a dropdown: there are four answers, they
769
- * are read left to right as increasing time, and a menu would hide three of
770
- * them behind the one that happens to be chosen. It reloads rather than
771
- * filtering what is already here — the totals, the models and the sessions
772
- * all answer for the window, and a page where the chart moved and the
773
- * figures above it did not would be lying about one of them.
790
+ * The account is the answer almost every visit wants; this machine's
791
+ * transcripts are the second look, for chat on a key someone brought. A
792
+ * switch as loud as the window and the kind would ask to be read first.
774
793
  */
794
+ const actions = el("span", "usage-head-actions");
795
+ if (usageSignedIn) {
796
+ const chosen = usageSource || "account";
797
+ const box = el("div", "seg usage-source");
798
+ for (const id of ["account", "device"]) {
799
+ const button = el("button", "seg-item" + (id === chosen ? " on" : ""), t("usage.source." + id));
800
+ button.type = "button";
801
+ button.addEventListener("click", () => {
802
+ if (id === chosen) return;
803
+ usageSource = id;
804
+ void showUsage();
805
+ });
806
+ box.appendChild(button);
807
+ }
808
+ actions.appendChild(box);
809
+ }
810
+ actions.appendChild(back);
811
+ head.appendChild(actions);
812
+ panel.appendChild(head);
813
+ }
814
+
815
+ /** The window presets and the calendar, reloading the page on a change. */
816
+ function usageWindowRow(body, presets) {
775
817
  const picked = body.range || "30d";
776
818
  // What the server says the window is, which is not always what was asked
777
819
  // for: a picked range is clamped to today and to a year, and the calendar
778
820
  // has to open on the days the figures beside it are actually counting.
779
821
  const today = new Date().toISOString().slice(0, 10);
780
- const shownFrom = body.from || today;
781
- const shownTo = body.to || today;
782
- const windowRow = el("div", "usage-window");
822
+ const row = el("div", "usage-window");
783
823
  const ranges = el("div", "seg");
784
- for (const id of ["7d", "30d", "90d", "all"]) {
824
+ for (const id of presets) {
785
825
  const button = el("button", "seg-item" + (id === picked ? " on" : ""), t("usage.range." + id));
786
826
  button.type = "button";
787
827
  button.addEventListener("click", () => {
@@ -791,20 +831,10 @@ async function showUsage() {
791
831
  });
792
832
  ranges.appendChild(button);
793
833
  }
794
- windowRow.appendChild(ranges);
795
- /*
796
- * A window the presets cannot express.
797
- *
798
- * Beside them rather than as a fifth item on the same control: the presets
799
- * are one click each and this one opens a calendar, and a segmented control
800
- * whose last segment behaves differently from the other four is a control
801
- * that has to be learned. It shows the days it is showing once it is the
802
- * chosen window, so the page never has a range on it that nothing on screen
803
- * names.
804
- */
805
- windowRow.appendChild(dateRangePicker({
806
- from: shownFrom,
807
- to: shownTo,
834
+ row.appendChild(ranges);
835
+ row.appendChild(dateRangePicker({
836
+ from: body.from || today,
837
+ to: body.to || today,
808
838
  max: today,
809
839
  maxDays: MAX_CUSTOM_DAYS,
810
840
  active: picked === "custom",
@@ -815,7 +845,315 @@ async function showUsage() {
815
845
  void showUsage();
816
846
  },
817
847
  }));
818
- panel.appendChild(windowRow);
848
+ return row;
849
+ }
850
+
851
+ /** Credits as a bill reads them: at most two decimals, and no trailing zeros. */
852
+ const usageCredits = (micro) => t("usage.credits", { n: creditsOf(micro) });
853
+ const usageCalls = (n) => (n === 1 ? t("usage.callOne") : t("usage.calls", { n: n.toLocaleString(LOCALE) }));
854
+ const usageSeconds = (n) => t("usage.seconds", { n: Math.round(n).toLocaleString(LOCALE) });
855
+
856
+ /**
857
+ * The account's spend: chat from every device, and every image, video and
858
+ * clip of audio, from the same ledger the site's usage page reads.
859
+ *
860
+ * Split by kind the way the site splits it, because a credit spent on a video
861
+ * and one spent on a chat are the same money bought very differently — and
862
+ * the question someone opening this after a week of generating is asking is
863
+ * "what did the pictures cost", which a list of forty models does not answer.
864
+ */
865
+ function showAccountUsage(body) {
866
+ panelBody.replaceChildren();
867
+ const panel = el("div", "panel usage");
868
+ usageHead(panel);
869
+
870
+ if (body.unavailable) {
871
+ panel.appendChild(el("p", "muted",
872
+ t(body.unavailable === "outdated" ? "usage.accountOutdated" : "usage.accountUnreachable")));
873
+ if (body.error) panel.appendChild(el("p", "muted small", body.error));
874
+ panelBody.appendChild(panel);
875
+ return;
876
+ }
877
+
878
+ panel.appendChild(usageWindowRow(body, ["24h", "7d", "30d", "90d"]));
879
+ const summary = body.summary || {};
880
+ const byKind = summary.byKind || {};
881
+ const kind = body.kind || "all";
882
+
883
+ /*
884
+ * The kinds, as the figures themselves.
885
+ *
886
+ * Each card is what that kind cost and how often it ran, and choosing one is
887
+ * the filter — so the split is read before anything is chosen, and there is
888
+ * no row of tabs repeating the numbers under it. What the chosen kind was
889
+ * counted in (tokens, pictures, seconds) is the line under the cards; the
890
+ * others are not known until they are chosen, because the ledger is read
891
+ * for one kind at a time.
892
+ */
893
+ const cards = el("div", "usage-kinds");
894
+ const total = Object.values(byKind).reduce((sum, each) => sum + (each.spentMicro || 0), 0);
895
+ const allCalls = Object.values(byKind).reduce((sum, each) => sum + (each.calls || 0), 0);
896
+ for (const id of USAGE_KINDS) {
897
+ const each = id === "all" ? { spentMicro: total, calls: allCalls } : byKind[id] || {};
898
+ const card = el("button", "usage-kind" + (id === kind ? " on" : "") + (each.spentMicro || each.calls ? "" : " empty"));
899
+ card.type = "button";
900
+ card.setAttribute("aria-pressed", id === kind ? "true" : "false");
901
+ card.appendChild(el("span", "usage-kind-label", t("usage.kind." + id)));
902
+ card.appendChild(el("span", "usage-kind-value", creditsOf(each.spentMicro || 0)));
903
+ card.appendChild(el("span", "usage-kind-sub", usageCalls(each.calls || 0)));
904
+ card.addEventListener("click", () => {
905
+ if (id === kind) return;
906
+ usageKind = id;
907
+ void showUsage();
908
+ });
909
+ cards.appendChild(card);
910
+ }
911
+ panel.appendChild(cards);
912
+
913
+ const media = summary.media || {};
914
+ const tokens = (summary.inputTokens || 0) + (summary.outputTokens || 0);
915
+ const counted = [];
916
+ if (tokens) {
917
+ counted.push(t("usage.tokens", { n: formatTokens(tokens) }) + " (" + t("usage.inOut", {
918
+ in: formatTokens(summary.inputTokens || 0),
919
+ out: formatTokens(summary.outputTokens || 0),
920
+ }) + ")");
921
+ }
922
+ if (media.images) counted.push(t("usage.images", { n: media.images.toLocaleString(LOCALE) }));
923
+ if (media.videoSeconds) counted.push(t("usage.videoSeconds", { n: usageSeconds(media.videoSeconds) }));
924
+ if (media.audioSeconds) counted.push(t("usage.audioSeconds", { n: usageSeconds(media.audioSeconds) }));
925
+ if (media.characters) counted.push(t("usage.characters", { n: media.characters.toLocaleString(LOCALE) }));
926
+ if (counted.length) panel.appendChild(el("div", "muted small usage-counted", counted.join(" · ")));
927
+
928
+ const series = summary.series || [];
929
+ if (series.length) {
930
+ const hourly = (summary.days || []).some((day) => day.at.length > 10);
931
+ const chartBody = {
932
+ series: {
933
+ step: hourly ? "hour" : "day",
934
+ buckets: (summary.days || []).map((day) => ({
935
+ at: day.at,
936
+ costUsd: (day.spentMicro || 0) / 1000000,
937
+ models: Object.fromEntries(Object.entries(day.byKey || {}).map(([key, value]) => [key, value / 1000000])),
938
+ })),
939
+ },
940
+ };
941
+ panel.appendChild(usageChart(chartBody, series.map((entry) => ({ model: entry.key })), {
942
+ money: (value) => usageCredits(value * 1000000),
943
+ // The ledger's buckets are UTC days and hours, and are read as such: a
944
+ // day drawn in local time would put yesterday's evening under today.
945
+ when: (at) => at.length > 10
946
+ ? new Date(at + ":00:00Z").toLocaleString(LOCALE, { month: "short", day: "numeric", hour: "numeric" })
947
+ : new Date(at + "T00:00:00Z").toLocaleDateString(LOCALE, { timeZone: "UTC" }),
948
+ }));
949
+
950
+ // By model, and under "all" by kind first: forty models in one list is a
951
+ // list nobody reads, and the kind is the first thing anyone looks for.
952
+ const groups = kind === "all" ? USAGE_KINDS.slice(1) : [kind];
953
+ for (const group of groups) {
954
+ const rows = series.filter((entry) => entry.kind === group);
955
+ if (!rows.length) continue;
956
+ const title = el("h3", "section-title usage-kind-title");
957
+ title.appendChild(el("span", null, kind === "all" ? t("usage.kind." + group) : t("usage.byModel")));
958
+ title.appendChild(el("span", "muted small",
959
+ usageCredits(rows.reduce((sum, entry) => sum + (entry.spentMicro || 0), 0))));
960
+ panel.appendChild(title);
961
+ for (const entry of rows) {
962
+ const row = el("div", "row-card");
963
+ const line = el("div", "row-head");
964
+ line.appendChild(el("span", "row-title", entry.key));
965
+ line.appendChild(el("span", "muted small usage-amount", usageCredits(entry.spentMicro || 0)));
966
+ row.appendChild(line);
967
+ row.appendChild(el("div", "muted small", usageCalls(entry.calls || 0)));
968
+ panel.appendChild(row);
969
+ }
970
+ }
971
+ } else {
972
+ panel.appendChild(el("p", "muted", t(kind === "all" ? "usage.none" : "usage.kindNone")));
973
+ }
974
+
975
+ // What the numbers cover, and where the full ledger lives.
976
+ panel.appendChild(el("p", "muted small", t(body.team ? "usage.teamScope" : "usage.accountScope")));
977
+ if (body.site) {
978
+ const open = el("button", "ghost", t("usage.openSite"));
979
+ open.type = "button";
980
+ open.addEventListener("click", () =>
981
+ window.open(body.site + "/" + LOCALE + "/dashboard/usage", "_blank", "noopener"));
982
+ panel.appendChild(open);
983
+ }
984
+ panelBody.appendChild(panel);
985
+ }
986
+
987
+ /** How many sessions the usage page lists before "View more". */
988
+ const USAGE_RECENT = 10;
989
+ /** Sessions per request on the sessions page. */
990
+ const USAGE_SESSIONS_PAGE = 50;
991
+
992
+ /** Prompt tokens sent, and everything moved, for a session row. */
993
+ const usageSent = (row) => (row.input || 0) + (row.cacheRead || 0) + (row.cacheWrite || 0);
994
+ const usageMoved = (row) => usageSent(row) + (row.output || 0);
995
+
996
+ /**
997
+ * One session: what it cost, when, on what, and how much of it the cache
998
+ * carried — and a way back into it, which is what someone scanning a list of
999
+ * what they spent usually wants next.
1000
+ */
1001
+ function usageSessionRow(entry) {
1002
+ const row = el("div", "row-card usage-session");
1003
+ const line = el("div", "row-head");
1004
+ line.appendChild(el("span", "row-title", entry.title || t("session.untitled")));
1005
+ if (entry.current) line.appendChild(el("span", "tag", t("usage.thisSession")));
1006
+ line.appendChild(el("span", "muted small usage-amount", "$" + (entry.costUsd || 0).toFixed(4)));
1007
+ row.appendChild(line);
1008
+ const detail = [ago(entry.updatedAt), entry.model, t("usage.tokens", { n: formatTokens(usageMoved(entry)) })];
1009
+ // The reason one session cost what it did is usually here: two runs of the
1010
+ // same length differ by what the cache carried.
1011
+ if (entry.cacheRead) detail.push(t("usage.cached", { pct: Math.round((entry.cacheRead / usageSent(entry)) * 100) }));
1012
+ row.appendChild(el("div", "muted small", detail.join(" · ")));
1013
+ row.tabIndex = 0;
1014
+ row.setAttribute("role", "button");
1015
+ const open = () => {
1016
+ setNav(null);
1017
+ if (panes.has(entry.id)) void focusTab(entry.id);
1018
+ else void openSession(entry.id, entry.cwd);
1019
+ };
1020
+ row.addEventListener("click", open);
1021
+ row.addEventListener("keydown", (event) => { if (event.key === "Enter") open(); });
1022
+ return row;
1023
+ }
1024
+
1025
+ /** What the sessions page is searching for, kept while the page is open. */
1026
+ let usageSessionsQuery = "";
1027
+
1028
+ /**
1029
+ * Every session that spent something on this machine, newest first.
1030
+ *
1031
+ * Read a page at a time and drawn as the list is scrolled, because a machine
1032
+ * used daily has thousands and the first screen is the one almost always
1033
+ * wanted. The search runs on the server, so it finds sessions that have not
1034
+ * been loaded yet rather than filtering the ones that have.
1035
+ */
1036
+ async function showUsageSessions() {
1037
+ panelBody.replaceChildren();
1038
+ const panel = el("div", "panel usage");
1039
+ const head = el("div", "row-head");
1040
+ head.appendChild(el("h2", null, t("usage.allSessions")));
1041
+ const back = el("button", "ghost", t("usage.backToUsage"));
1042
+ back.type = "button";
1043
+ back.addEventListener("click", () => {
1044
+ setNav("usage");
1045
+ void showUsage();
1046
+ });
1047
+ head.appendChild(back);
1048
+ panel.appendChild(head);
1049
+
1050
+ const search = el("input", "art-search");
1051
+ search.type = "search";
1052
+ search.placeholder = t("usage.searchSessions");
1053
+ search.spellcheck = false;
1054
+ search.autocomplete = "off";
1055
+ search.value = usageSessionsQuery;
1056
+ panel.appendChild(search);
1057
+
1058
+ const count = el("div", "muted small usage-sessions-count");
1059
+ panel.appendChild(count);
1060
+ const list = el("div", "usage-sessions");
1061
+ panel.appendChild(list);
1062
+ const foot = el("div", "muted small usage-sessions-foot");
1063
+ panel.appendChild(foot);
1064
+ panelBody.appendChild(panel);
1065
+
1066
+ // Which search the rows on screen belong to. A reply to an older search
1067
+ // that lands after a newer one started is dropped, not appended.
1068
+ let generation = 0;
1069
+ let offset = 0;
1070
+ let more = true;
1071
+ let loading = false;
1072
+
1073
+ async function load() {
1074
+ if (loading || !more) return;
1075
+ loading = true;
1076
+ const asked = generation;
1077
+ foot.textContent = t("usage.loading");
1078
+ const body = await getJson("/usage/sessions?limit=" + USAGE_SESSIONS_PAGE + "&offset=" + offset +
1079
+ (usageSessionsQuery ? "&q=" + encodeURIComponent(usageSessionsQuery) : ""));
1080
+ loading = false;
1081
+ if (asked !== generation || shownPanel !== "usage-sessions") return;
1082
+ if (body.error) {
1083
+ foot.textContent = body.error;
1084
+ return;
1085
+ }
1086
+ for (const entry of body.sessions || []) list.appendChild(usageSessionRow(entry));
1087
+ offset += (body.sessions || []).length;
1088
+ more = Boolean(body.more);
1089
+ count.textContent = body.total === 1 ? t("usage.sessionsOne") : t("usage.sessionsCount", { n: body.total || 0 });
1090
+ foot.textContent = !body.total
1091
+ ? t(usageSessionsQuery ? "usage.sessionsNoMatch" : "usage.none")
1092
+ : more ? "" : body.truncated ? t("usage.sessionsCapped") : t("usage.sessionsEnd");
1093
+ // A first page shorter than the window leaves nothing to scroll, so the
1094
+ // observer below would never fire; ask again until the list fills it.
1095
+ if (more && sentinel.getBoundingClientRect().top < panelView.clientHeight + 400) void load();
1096
+ }
1097
+
1098
+ function restart() {
1099
+ generation += 1;
1100
+ offset = 0;
1101
+ more = true;
1102
+ loading = false;
1103
+ list.replaceChildren();
1104
+ void load();
1105
+ }
1106
+
1107
+ // Loads the next page as the bottom of the list comes near, not when it is
1108
+ // reached, so scrolling does not stop at a spinner.
1109
+ const sentinel = el("div", "usage-sessions-sentinel");
1110
+ panel.appendChild(sentinel);
1111
+ const observer = new IntersectionObserver((entries) => {
1112
+ if (shownPanel !== "usage-sessions") return void observer.disconnect();
1113
+ if (entries.some((entry) => entry.isIntersecting)) void load();
1114
+ }, { root: panelView, rootMargin: "0px 0px 400px 0px" });
1115
+ observer.observe(sentinel);
1116
+
1117
+ let typing = null;
1118
+ search.addEventListener("input", () => {
1119
+ clearTimeout(typing);
1120
+ typing = setTimeout(() => {
1121
+ usageSessionsQuery = search.value.trim();
1122
+ restart();
1123
+ }, 250);
1124
+ });
1125
+ restart();
1126
+ search.focus();
1127
+ }
1128
+
1129
+ async function showUsage() {
1130
+ if (usageSource !== "device") {
1131
+ const account = await getJson(
1132
+ usageQuery("/usage/account") + (usageKind !== "all" ? "&kind=" + encodeURIComponent(usageKind) : ""),
1133
+ );
1134
+ if (shownPanel !== "usage") return;
1135
+ usageSignedIn = Boolean(account.signedIn);
1136
+ if (account.signedIn) return void showAccountUsage(account);
1137
+ }
1138
+ const body = await getJson(usageQuery("/usage"));
1139
+ if (shownPanel !== "usage") return;
1140
+ panelBody.replaceChildren();
1141
+ if (body.error) return void panelBody.appendChild(panelError(body.error));
1142
+
1143
+ const panel = el("div", "panel usage");
1144
+ usageHead(panel);
1145
+
1146
+ /*
1147
+ * How far back the page is asking about.
1148
+ *
1149
+ * A segmented control rather than a dropdown: there are four answers, they
1150
+ * are read left to right as increasing time, and a menu would hide three of
1151
+ * them behind the one that happens to be chosen. It reloads rather than
1152
+ * filtering what is already here — the totals, the models and the sessions
1153
+ * all answer for the window, and a page where the chart moved and the
1154
+ * figures above it did not would be lying about one of them.
1155
+ */
1156
+ panel.appendChild(usageWindowRow(body, ["7d", "30d", "90d", "all"]));
819
1157
 
820
1158
  // Prompt tokens only. The cache share is a share of what was sent, and
821
1159
  // nothing that was generated could have come from a cache.
@@ -881,7 +1219,7 @@ async function showUsage() {
881
1219
  const row = el("div", "row-card");
882
1220
  const line = el("div", "row-head");
883
1221
  line.appendChild(el("span", "row-title", entry.model));
884
- line.appendChild(el("span", "muted small", "$" + (entry.costUsd || 0).toFixed(2)));
1222
+ line.appendChild(el("span", "muted small usage-amount", "$" + (entry.costUsd || 0).toFixed(2)));
885
1223
  row.appendChild(line);
886
1224
  const bits = [
887
1225
  t("usage.tokens", { n: formatTokens(moved(entry)) }),
@@ -896,27 +1234,19 @@ async function showUsage() {
896
1234
 
897
1235
  const sessions = body.sessions || [];
898
1236
  if (sessions.length) {
899
- panel.appendChild(el("h3", "section-title", t("usage.bySession")));
900
- for (const entry of sessions) {
901
- const row = el("div", "row-card");
902
- const line = el("div", "row-head");
903
- line.appendChild(el("span", "row-title", entry.title || t("session.untitled")));
904
- if (entry.current) line.appendChild(el("span", "tag", t("usage.thisSession")));
905
- line.appendChild(el("span", "muted small", "$" + (entry.costUsd || 0).toFixed(4)));
906
- row.appendChild(line);
907
- const detail = [
908
- ago(entry.updatedAt),
909
- entry.model,
910
- t("usage.tokens", { n: formatTokens(moved(entry)) }),
911
- ];
912
- // The reason one session cost what it did is usually here: two runs of
913
- // the same length differ by what the cache carried, and per-session is
914
- // the only place that comparison can be made.
915
- const hit = cachePct(entry);
916
- if (hit !== null) detail.push(t("usage.cached", { pct: hit }));
917
- row.appendChild(el("div", "muted small", detail.join(" · ")));
918
- panel.appendChild(row);
919
- }
1237
+ // The latest few, and a way to all of them: a page of rows here pushed
1238
+ // the scope note below off the screen and still was not every session.
1239
+ const title = el("div", "usage-section-head");
1240
+ title.appendChild(el("h3", "section-title", t("usage.bySession")));
1241
+ const more = el("button", "ghost", t("usage.moreSessions"));
1242
+ more.type = "button";
1243
+ more.addEventListener("click", () => {
1244
+ setNav("usage-sessions");
1245
+ void showUsageSessions();
1246
+ });
1247
+ title.appendChild(more);
1248
+ panel.appendChild(title);
1249
+ for (const entry of sessions.slice(0, USAGE_RECENT)) panel.appendChild(usageSessionRow(entry));
920
1250
  }
921
1251
 
922
1252
  if (!models.length) panel.appendChild(el("p", "muted", t("usage.none")));
@@ -1 +1 @@
1
- {"version":3,"file":"usage.js","sourceRoot":"","sources":["../../../src/page/client/usage.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AACH,MAAM,CAAC,MAAM,KAAK,GAAG,MAAM,CAAC,GAAG,CAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;IAk7B3B,CAAC"}
1
+ {"version":3,"file":"usage.js","sourceRoot":"","sources":["../../../src/page/client/usage.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AACH,MAAM,CAAC,MAAM,KAAK,GAAG,MAAM,CAAC,GAAG,CAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;IA4vC3B,CAAC"}