pilotswarm 0.5.1 → 0.5.2

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 (33) hide show
  1. package/mcp/dist/src/session-id.d.ts +13 -0
  2. package/mcp/dist/src/session-id.d.ts.map +1 -0
  3. package/mcp/dist/src/session-id.js +15 -0
  4. package/mcp/dist/src/session-id.js.map +1 -0
  5. package/mcp/dist/src/tools/artifacts.d.ts.map +1 -1
  6. package/mcp/dist/src/tools/artifacts.js +5 -4
  7. package/mcp/dist/src/tools/artifacts.js.map +1 -1
  8. package/mcp/dist/src/tools/observability.d.ts.map +1 -1
  9. package/mcp/dist/src/tools/observability.js +2 -1
  10. package/mcp/dist/src/tools/observability.js.map +1 -1
  11. package/mcp/dist/src/tools/sessions.d.ts.map +1 -1
  12. package/mcp/dist/src/tools/sessions.js +9 -8
  13. package/mcp/dist/src/tools/sessions.js.map +1 -1
  14. package/mcp/dist/src/tools/turn-control.d.ts.map +1 -1
  15. package/mcp/dist/src/tools/turn-control.js +5 -4
  16. package/mcp/dist/src/tools/turn-control.js.map +1 -1
  17. package/package.json +3 -3
  18. package/tui/plugins/.mcp.json +1 -7
  19. package/tui/src/node-sdk-transport.js +17 -0
  20. package/ui/core/src/controller.js +56 -8
  21. package/ui/core/src/reducer.js +87 -0
  22. package/ui/core/src/selectors.js +154 -123
  23. package/ui/core/src/state.js +15 -0
  24. package/ui/react/src/web-app.js +60 -22
  25. package/web/dist/assets/index-BA1Gd5ZC.js +24 -0
  26. package/web/dist/assets/index-Jr_Wn0fg.css +1 -0
  27. package/web/dist/assets/pilotswarm-C6iSDexX.js +90 -0
  28. package/web/dist/assets/{react-CRYBfNR4.js → react-CqzaLZob.js} +1 -1
  29. package/web/dist/index.html +4 -4
  30. package/web/runtime.js +23 -2
  31. package/web/dist/assets/index-BmqU2WsR.js +0 -24
  32. package/web/dist/assets/index-D2-7fi56.css +0 -1
  33. package/web/dist/assets/pilotswarm-cIaYAE9e.js +0 -90
@@ -1488,6 +1488,7 @@ export function appReducer(state, action) {
1488
1488
  saving: false,
1489
1489
  error: null,
1490
1490
  lastSavedAt: state.admin.ghcpKey.lastSavedAt,
1491
+ storeAsSystem: Boolean(state.admin.ghcpKey.storeAsSystem),
1491
1492
  },
1492
1493
  },
1493
1494
  };
@@ -1524,6 +1525,7 @@ export function appReducer(state, action) {
1524
1525
  saving: false,
1525
1526
  error: null,
1526
1527
  lastSavedAt: state.admin.ghcpKey.lastSavedAt,
1528
+ storeAsSystem: Boolean(state.admin.ghcpKey.storeAsSystem),
1527
1529
  },
1528
1530
  },
1529
1531
  };
@@ -1567,6 +1569,91 @@ export function appReducer(state, action) {
1567
1569
  saving: false,
1568
1570
  error: null,
1569
1571
  lastSavedAt: Date.now(),
1572
+ storeAsSystem: Boolean(state.admin.ghcpKey.storeAsSystem),
1573
+ },
1574
+ },
1575
+ };
1576
+ }
1577
+ case "admin/ghcpKey/setSystemTarget": {
1578
+ const storeAsSystem = Boolean(action.value);
1579
+ if (Boolean(state.admin.ghcpKey.storeAsSystem) === storeAsSystem) return state;
1580
+ return {
1581
+ ...state,
1582
+ admin: {
1583
+ ...state.admin,
1584
+ ghcpKey: {
1585
+ ...state.admin.ghcpKey,
1586
+ storeAsSystem,
1587
+ error: null,
1588
+ },
1589
+ },
1590
+ };
1591
+ }
1592
+ case "admin/systemGhcpKey/loading": {
1593
+ return {
1594
+ ...state,
1595
+ admin: {
1596
+ ...state.admin,
1597
+ systemGhcpKey: {
1598
+ ...state.admin.systemGhcpKey,
1599
+ loading: true,
1600
+ error: null,
1601
+ },
1602
+ },
1603
+ };
1604
+ }
1605
+ case "admin/systemGhcpKey/loaded": {
1606
+ const status = action.status || {};
1607
+ return {
1608
+ ...state,
1609
+ admin: {
1610
+ ...state.admin,
1611
+ systemGhcpKey: {
1612
+ supported: true,
1613
+ loading: false,
1614
+ configured: Boolean(status.configured),
1615
+ changedBy: status.changedBy || null,
1616
+ changedAt: status.changedAt || null,
1617
+ error: null,
1618
+ },
1619
+ },
1620
+ };
1621
+ }
1622
+ case "admin/systemGhcpKey/loadFailed": {
1623
+ return {
1624
+ ...state,
1625
+ admin: {
1626
+ ...state.admin,
1627
+ systemGhcpKey: {
1628
+ ...state.admin.systemGhcpKey,
1629
+ loading: false,
1630
+ error: action.error ? String(action.error) : "Failed to load System key status",
1631
+ },
1632
+ },
1633
+ };
1634
+ }
1635
+ case "admin/systemGhcpKey/saved": {
1636
+ const status = action.status || {};
1637
+ return {
1638
+ ...state,
1639
+ admin: {
1640
+ ...state.admin,
1641
+ ghcpKey: {
1642
+ editing: false,
1643
+ draft: "",
1644
+ cursorIndex: 0,
1645
+ saving: false,
1646
+ error: null,
1647
+ lastSavedAt: Date.now(),
1648
+ storeAsSystem: Boolean(state.admin.ghcpKey.storeAsSystem),
1649
+ },
1650
+ systemGhcpKey: {
1651
+ supported: true,
1652
+ loading: false,
1653
+ configured: Boolean(status.configured),
1654
+ changedBy: status.changedBy || null,
1655
+ changedAt: status.changedAt || null,
1656
+ error: null,
1570
1657
  },
1571
1658
  },
1572
1659
  };
@@ -1887,60 +1887,6 @@ const LIVE_ACTIVITY_SKIP = new Set([
1887
1887
  "tool.execution_progress",
1888
1888
  ]);
1889
1889
 
1890
- // Consecutive events of these types collapse into a single line (streaming
1891
- // reasoning/intent fires many events per turn).
1892
- const LIVE_ACTIVITY_COLLAPSE = new Set(["assistant.reasoning", "assistant.intent"]);
1893
-
1894
- // Friendly verbs for the live activity lines, keyed by event type. An empty
1895
- // string means "show the detail as-is" (e.g. tool calls, where the tool name is
1896
- // the action). Types absent from the map fall back to their cleaned detail.
1897
- const LIVE_ACTIVITY_VERBS = {
1898
- "tool.execution_start": "",
1899
- "tool.execution_complete": "",
1900
- "assistant.reasoning": "Thinking",
1901
- "assistant.intent": "Planning",
1902
- "session.agent_spawned": "Spawning sub-agent",
1903
- "session.wait_started": "Waiting",
1904
- "session.input_required_started": "Awaiting input",
1905
- "session.command_received": "Command",
1906
- "session.command_completed": "Command",
1907
- "session.compaction_start": "Compacting context",
1908
- "session.compaction_complete": "Context compacted",
1909
- "session.dehydrated": "Dehydrating",
1910
- "session.hydrated": "Rehydrated",
1911
- "session.rehydrated": "Rehydrated",
1912
- "session.lossy_handoff": "Handed off to a new worker",
1913
- "session.error": "Error",
1914
- "session.cron_started": "Scheduled",
1915
- "session.cron_scheduled": "Scheduled",
1916
- "session.cron_fired": "Woke on schedule",
1917
- "session.cron_at_scheduled": "Scheduled",
1918
- "session.cron_at_started": "Scheduled",
1919
- "session.cron_at_fired": "Woke on schedule",
1920
- "system.message": "",
1921
- };
1922
-
1923
- // Turn a raw activity item into a friendly one-line action label: strip the
1924
- // timestamp and [bracket] tag, then prefix a human verb where one applies.
1925
- function friendlyLiveActivity(item, maxLen = 96) {
1926
- const raw = String(item?.text || "")
1927
- .replace(/^\s*\d{1,2}:\d{2}(?::\d{2})?\s*/, "")
1928
- .replace(/^\[[^\]]+\]\s*/, "")
1929
- .replace(/\s+/g, " ")
1930
- .trim();
1931
- const verb = LIVE_ACTIVITY_VERBS[item?.eventType];
1932
- let text;
1933
- if (verb === undefined || verb === "") {
1934
- text = raw;
1935
- } else if (raw) {
1936
- text = `${verb} \u2014 ${raw}`;
1937
- } else {
1938
- text = verb;
1939
- }
1940
- text = text || "Working";
1941
- return text.length > maxLen ? `${text.slice(0, maxLen - 1)}\u2026` : text;
1942
- }
1943
-
1944
1890
  // Format an elapsed duration for the live activity card header (e.g. "12s",
1945
1891
  // "1m 05s", "1h 02m").
1946
1892
  function formatElapsed(ms) {
@@ -1954,12 +1900,87 @@ function formatElapsed(ms) {
1954
1900
  return `${hours}h ${String(mins).padStart(2, "0")}m`;
1955
1901
  }
1956
1902
 
1957
- // Claude-style live activity: while a turn is running, surface the last few
1958
- // actions as a short text scrollback. Assistant messages can land before the
1959
- // turn is actually done (streamed/tool-interleaved replies), so the card is
1960
- // governed by session status rather than by the newest transcript role.
1961
- // `options.spinnerFrame` animates the active (latest) line's marker;
1962
- // `options.maxActions` caps the line count.
1903
+ // Fact-store, graph-store, and skill tools get first-class phases instead of
1904
+ // the generic "running tool: X". Classification is pattern-based so new tools
1905
+ // in these families inherit the right phase without a table update.
1906
+ const FACTS_READ_TOOLS = new Set(["read_facts", "facts_search", "facts_similar", "facts_read_uncrawled", "facts_tombstone_stats"]);
1907
+ const FACTS_WRITE_TOOLS = new Set(["store_fact", "delete_fact", "facts_set_crawled", "facts_purge_tombstones", "facts_force_purge"]);
1908
+ function knowledgeToolPhase(name, starting) {
1909
+ const n = String(name || "").toLowerCase();
1910
+ if (n === "search_skills" || n.includes("skill")) {
1911
+ return starting ? "loading skills\u2026" : "loaded skills";
1912
+ }
1913
+ if (FACTS_READ_TOOLS.has(n)) return starting ? "reading facts\u2026" : "read facts";
1914
+ if (FACTS_WRITE_TOOLS.has(n)) return starting ? "writing facts\u2026" : "wrote facts";
1915
+ if (n.startsWith("graph_")) {
1916
+ const writes = /upsert|delete|archive|merge|remove|set_/.test(n);
1917
+ if (writes) return starting ? "writing to the graph\u2026" : "wrote to the graph";
1918
+ return starting ? "reading the graph\u2026" : "read the graph";
1919
+ }
1920
+ if (n.startsWith("facts_") || n.endsWith("_fact") || n.endsWith("_facts")) {
1921
+ // Unrecognized fact-family tool: read/write by verb.
1922
+ const writes = /store|write|set|delete|purge|update/.test(n);
1923
+ return writes ? (starting ? "writing facts\u2026" : "wrote facts") : (starting ? "reading facts\u2026" : "read facts");
1924
+ }
1925
+ return "";
1926
+ }
1927
+
1928
+ // High-level phase for the live activity status line. Maps the newest
1929
+ // activity event to a short human description ("thinking\u2026", "running tool:
1930
+ // read_file\u2026") — never raw payloads; detail lives in the Inspector.
1931
+ function liveActivityPhaseText(item) {
1932
+ const type = String(item?.eventType || "");
1933
+ if (type === "tool.execution_start" || type === "tool.execution_complete") {
1934
+ const raw = String(item?.text || "")
1935
+ .replace(/^\s*\d{1,2}:\d{2}(?::\d{2})?\s*/, "")
1936
+ .replace(/^\[[^\]]+\]\s*/, "")
1937
+ .replace(/^[^A-Za-z0-9_]+/, "")
1938
+ .trim();
1939
+ const name = (raw.match(/^[A-Za-z0-9_.:-]+/) || [""])[0];
1940
+ if (!name) return "running tools\u2026";
1941
+ const knowledge = knowledgeToolPhase(name, type === "tool.execution_start");
1942
+ if (knowledge) return knowledge;
1943
+ const shortName = name.length > 28 ? `${name.slice(0, 27)}\u2026` : name;
1944
+ return type === "tool.execution_start"
1945
+ ? `running tool: ${shortName}\u2026`
1946
+ : `finished tool: ${shortName}`;
1947
+ }
1948
+ switch (type) {
1949
+ case "skill.invoked": return "loading skills\u2026";
1950
+ case "learned_skill.read": return "loading skills\u2026";
1951
+ case "skills.searched": return "loading skills\u2026";
1952
+ case "facts.searched":
1953
+ case "facts.similar": return "reading facts\u2026";
1954
+ case "graph.searched":
1955
+ case "graph.node_searched":
1956
+ case "graph.node_loaded": return "reading the graph\u2026";
1957
+ case "graph.namespace_mutated": return "writing to the graph\u2026";
1958
+ case "assistant.reasoning": return "thinking\u2026";
1959
+ case "assistant.intent": return "planning\u2026";
1960
+ case "assistant.message_start":
1961
+ case "assistant.message": return "writing reply\u2026";
1962
+ case "session.compaction_start": return "compacting context\u2026";
1963
+ case "session.compaction_complete": return "context compacted";
1964
+ case "session.dehydrated": return "dehydrating\u2026";
1965
+ case "session.hydrated":
1966
+ case "session.rehydrated": return "rehydrating\u2026";
1967
+ case "session.agent_spawned": return "coordinating agents\u2026";
1968
+ case "session.wait_started": return "waiting\u2026";
1969
+ case "session.input_required_started": return "awaiting input\u2026";
1970
+ case "session.lossy_handoff": return "moving to a new worker\u2026";
1971
+ case "session.error": return "recovering from an error\u2026";
1972
+ default: return "";
1973
+ }
1974
+ }
1975
+
1976
+ // Single-line live activity status: while a turn is running, one dim line —
1977
+ // spinner + "Working" + elapsed + a high-level phase. Replaces the old
1978
+ // bordered multi-line card: hosts pin it OUTSIDE the scrolling transcript
1979
+ // (portal: bottom-sticky strip above the composer; TUI: appended below the
1980
+ // transcript, which autoscroll keeps at the bottom). Assistant messages can
1981
+ // land before the turn is actually done (streamed/tool-interleaved replies),
1982
+ // so visibility is governed by session status rather than transcript role.
1983
+ // `options.spinnerFrame` animates the marker.
1963
1984
  export function selectLiveActivityLines(state, options = {}) {
1964
1985
  if (state?.ui?.chatViewMode === "summary") return [];
1965
1986
  const session = selectActiveSession(state);
@@ -1967,76 +1988,60 @@ export function selectLiveActivityLines(state, options = {}) {
1967
1988
  const status = String(session?.status || "").toLowerCase();
1968
1989
  if (status !== "running") return [];
1969
1990
  const history = state.history?.bySessionId?.get(session.sessionId) || null;
1970
- // Use the latest user message as the elapsed-time anchor when available,
1971
- // but do not hide the card just because assistant text has already landed;
1972
- // the session may still be running tools or follow-up model work.
1991
+ // Latest user message anchors the elapsed clock when available.
1973
1992
  const chat = Array.isArray(history?.chat) ? history.chat : [];
1974
1993
  let turnStartAt = 0;
1975
1994
  for (let i = chat.length - 1; i >= 0; i -= 1) {
1976
- const role = chat[i]?.role;
1977
- if (role === "user") {
1995
+ if (chat[i]?.role === "user") {
1978
1996
  turnStartAt = Number(chat[i]?.createdAt || 0);
1979
1997
  break;
1980
1998
  }
1981
1999
  }
1982
2000
  const activity = Array.isArray(history?.activity) ? history.activity : [];
1983
- const maxActions = Number.isFinite(options.maxActions) ? options.maxActions : 3;
1984
- const recent = [];
1985
- let acceptedType = null;
1986
- let oldestGatheredAt = 0;
1987
- for (let i = activity.length - 1; i >= 0 && recent.length < maxActions; i -= 1) {
2001
+ // Current-turn boundaries. When a new turn starts, status flips to
2002
+ // "running" BEFORE fresh history lands; anchoring the clock on stale
2003
+ // data flashes a huge elapsed (the whole idle gap) that then snaps to
2004
+ // the real value. Anchor only on evidence belonging to THIS turn — a
2005
+ // user message or turn_start newer than the last turn end and show
2006
+ // no timer (and no phase) until such evidence exists.
2007
+ let lastTurnStartAt = 0;
2008
+ let lastTurnEndAt = 0;
2009
+ for (let i = activity.length - 1; i >= 0; i -= 1) {
2010
+ const item = activity[i];
2011
+ const type = item?.eventType;
2012
+ const at = Number(item?.createdAt || 0);
2013
+ if (!lastTurnStartAt && type === "assistant.turn_start") lastTurnStartAt = at;
2014
+ if (!lastTurnEndAt && (type === "assistant.turn_end" || type === "session.turn_completed")) lastTurnEndAt = at;
2015
+ if (lastTurnStartAt && lastTurnEndAt) break;
2016
+ }
2017
+ let startAt = 0;
2018
+ if (turnStartAt > lastTurnEndAt) {
2019
+ startAt = turnStartAt; // user-triggered turn
2020
+ } else if (lastTurnStartAt > lastTurnEndAt) {
2021
+ startAt = lastTurnStartAt; // cron/command-triggered turn
2022
+ }
2023
+ let latest = null;
2024
+ for (let i = activity.length - 1; i >= 0; i -= 1) {
1988
2025
  const item = activity[i];
1989
2026
  if (!item || LIVE_ACTIVITY_SKIP.has(item.eventType)) continue;
1990
- if (item.eventType === acceptedType && LIVE_ACTIVITY_COLLAPSE.has(item.eventType)) continue;
1991
- const detail = friendlyLiveActivity(item);
1992
- if (!detail) continue;
1993
- recent.unshift(detail);
1994
- acceptedType = item.eventType;
1995
- const at = Number(item.createdAt || 0);
1996
- if (at > 0) oldestGatheredAt = oldestGatheredAt ? Math.min(oldestGatheredAt, at) : at;
1997
- }
1998
- if (recent.length === 0) return [];
1999
- // Render as a bordered card: the portal turns box-drawing lines whose top
2000
- // row has >2 runs into a `ps-chat-card`; the terminal shows the box art.
2001
- // Header carries the animated spinner + "Working"; body lists the recent
2002
- // actions, most recent highlighted.
2003
- const borderColor = "cyan";
2004
- const spinner = String(options.spinnerFrame || "").trim() || "\u25cf";
2027
+ // Phase must come from the current turn — a stale "finished tool"
2028
+ // from the previous turn must not flash on turn start.
2029
+ if (Number(item.createdAt || 0) <= lastTurnEndAt) break;
2030
+ latest = item;
2031
+ break;
2032
+ }
2005
2033
  const nowMs = Number.isFinite(options.now) ? options.now : Date.now();
2006
- const startAt = turnStartAt || oldestGatheredAt;
2007
2034
  const elapsedLabel = startAt > 0 ? formatElapsed(nowMs - startAt) : "";
2008
- const titleRuns = [
2009
- { text: ` ${spinner} Working `, color: borderColor, bold: true },
2010
- ...(elapsedLabel ? [{ text: `\u00b7 ${elapsedLabel} `, color: "gray" }] : []),
2035
+ const spinner = String(options.spinnerFrame || "").trim() || "\u25cf";
2036
+ const phase = latest ? liveActivityPhaseText(latest) : "";
2037
+ const runs = [
2038
+ { text: `${spinner} `, color: "cyan" },
2039
+ { text: "Working", color: "cyan" },
2040
+ ...(elapsedLabel ? [{ text: ` \u00b7 ${elapsedLabel}`, color: "gray" }] : []),
2041
+ ...(phase ? [{ text: ` \u2014 ${phase}`, color: "gray" }] : []),
2011
2042
  ];
2012
- const titleWidth = titleRuns.reduce((sum, run) => sum + String(run.text || "").length, 0);
2013
- const maxWidth = Math.max(24, Math.min(96, Math.floor(Number(options.maxWidth) || 64)));
2014
- const widestBody = recent.reduce((max, detail) => Math.max(max, detail.length + 2), 0);
2015
- const cardWidth = Math.max(20, Math.min(maxWidth, Math.max(titleWidth + 3, widestBody + 4)));
2016
- const contentWidth = Math.max(1, cardWidth - 4);
2017
- const bodyLines = recent.map((detail, index) => {
2018
- const isLatest = index === recent.length - 1;
2019
- return fitRuns([
2020
- { text: isLatest ? "\u25cf " : "\u00b7 ", color: isLatest ? "cyan" : "gray", bold: isLatest },
2021
- { text: detail, color: isLatest ? "cyan" : "gray" },
2022
- ], contentWidth);
2023
- });
2024
- const topFill = Math.max(0, cardWidth - titleWidth - 3);
2025
- const card = [[
2026
- { text: "\u250c\u2500", color: borderColor },
2027
- ...titleRuns,
2028
- { text: `${"\u2500".repeat(topFill)}\u2510`, color: borderColor },
2029
- ]];
2030
- for (const runs of bodyLines) {
2031
- card.push([
2032
- { text: "\u2502 ", color: borderColor },
2033
- ...padRunsToDisplayWidth(runs, contentWidth),
2034
- { text: " \u2502", color: borderColor },
2035
- ]);
2036
- }
2037
- card.push([{ text: `\u2514${"\u2500".repeat(Math.max(1, cardWidth - 2))}\u2518`, color: borderColor }]);
2038
- card.push([{ text: "", color: null }]);
2039
- return card;
2043
+ const maxWidth = Math.max(24, Math.min(120, Math.floor(Number(options.maxWidth) || 80)));
2044
+ return [fitRuns(runs, maxWidth)];
2040
2045
  }
2041
2046
 
2042
2047
  export function selectChatLines(state, maxWidth = 80, options = {}) {
@@ -2735,19 +2740,34 @@ export function selectAdminConsole(state) {
2735
2740
  }
2736
2741
  : (state.auth?.principal || null);
2737
2742
 
2743
+ const systemGhcpKey = admin.systemGhcpKey || { supported: false, loading: false, configured: false, changedBy: null, changedAt: null, error: null };
2744
+ const isAdmin = Boolean(profile?.isAdmin);
2745
+ // Admin-only target switch: when on, Set/Replace/Clear act on the
2746
+ // SYSTEM user's key (ownerless system sessions run on it).
2747
+ const storeAsSystem = isAdmin && Boolean(ghcpKey.storeAsSystem);
2748
+ const targetConfigured = storeAsSystem ? Boolean(systemGhcpKey.configured) : Boolean(profile?.githubCopilotKeySet);
2749
+ const keyNoun = storeAsSystem ? "System key" : "key";
2750
+
2751
+ const systemProvenance = systemGhcpKey.configured && systemGhcpKey.changedBy
2752
+ ? ` (set by ${systemGhcpKey.changedBy})`
2753
+ : "";
2738
2754
  const ghcpStatusText = ghcpKey.saving
2739
2755
  ? "Saving..."
2740
2756
  : ghcpKey.editing
2741
2757
  ? "Editing — Enter to save, Esc to cancel"
2742
- : (profile?.githubCopilotKeySet
2743
- ? "Configured (overrides env GITHUB_TOKEN for this user)"
2744
- : "Not configured (using env GITHUB_TOKEN fallback)");
2758
+ : storeAsSystem
2759
+ ? (systemGhcpKey.configured
2760
+ ? `System key configured ownerless system sessions use it for GitHub Copilot models${systemProvenance}`
2761
+ : "System key not configured — ownerless system sessions fall back to env GITHUB_TOKEN")
2762
+ : (profile?.githubCopilotKeySet
2763
+ ? "Configured (overrides env GITHUB_TOKEN for this user)"
2764
+ : "Not configured (using env GITHUB_TOKEN fallback)");
2745
2765
 
2746
2766
  const actions = [];
2747
2767
  if (!ghcpKey.editing) {
2748
- actions.push({ id: "edit", label: profile?.githubCopilotKeySet ? "Replace key" : "Set key", key: "e" });
2749
- if (profile?.githubCopilotKeySet) {
2750
- actions.push({ id: "clear", label: "Clear key", key: "c" });
2768
+ actions.push({ id: "edit", label: targetConfigured ? `Replace ${keyNoun}` : `Set ${keyNoun}`, key: "e" });
2769
+ if (targetConfigured) {
2770
+ actions.push({ id: "clear", label: `Clear ${keyNoun}`, key: "c" });
2751
2771
  }
2752
2772
  actions.push({ id: "refresh", label: "Refresh", key: "r" });
2753
2773
  } else {
@@ -2761,8 +2781,11 @@ export function selectAdminConsole(state) {
2761
2781
  loading: Boolean(admin.loading),
2762
2782
  loadError: admin.loadError || null,
2763
2783
  principal,
2784
+ isAdmin,
2764
2785
  ghcpKey: {
2765
2786
  configured: Boolean(profile?.githubCopilotKeySet),
2787
+ targetConfigured,
2788
+ storeAsSystem,
2766
2789
  editing: Boolean(ghcpKey.editing),
2767
2790
  draft: ghcpKey.draft || "",
2768
2791
  cursorIndex: Math.max(0, Math.min(Number(ghcpKey.cursorIndex) || 0, String(ghcpKey.draft || "").length)),
@@ -2770,6 +2793,14 @@ export function selectAdminConsole(state) {
2770
2793
  error: ghcpKey.error || null,
2771
2794
  statusText: ghcpStatusText,
2772
2795
  },
2796
+ systemGhcpKey: {
2797
+ supported: Boolean(systemGhcpKey.supported),
2798
+ loading: Boolean(systemGhcpKey.loading),
2799
+ configured: Boolean(systemGhcpKey.configured),
2800
+ changedBy: systemGhcpKey.changedBy || null,
2801
+ changedAt: systemGhcpKey.changedAt || null,
2802
+ error: systemGhcpKey.error || null,
2803
+ },
2773
2804
  actions,
2774
2805
  };
2775
2806
  }
@@ -247,6 +247,21 @@ export function createInitialState({ mode = "local", branding = null, themeId =
247
247
  saving: false,
248
248
  error: null,
249
249
  lastSavedAt: 0,
250
+ // Admin-only target switch: when true, Set/Replace/Clear
251
+ // act on the SYSTEM user's key (used by ownerless system
252
+ // sessions) instead of the caller's own key.
253
+ storeAsSystem: false,
254
+ },
255
+ // Status of the SYSTEM user's Copilot key (admins only; never
256
+ // the key itself). `supported` flips on the first successful
257
+ // status load so non-admin/legacy transports hide the toggle.
258
+ systemGhcpKey: {
259
+ supported: false,
260
+ loading: false,
261
+ configured: false,
262
+ changedBy: null,
263
+ changedAt: null,
264
+ error: null,
250
265
  },
251
266
  },
252
267
  };
@@ -431,13 +431,19 @@ function compactTitleRuns(title, maxWidth = 40) {
431
431
  return [{ text: text.length > maxWidth ? `${text.slice(0, maxWidth - 1)}…` : text, color: "white", bold: true }];
432
432
  }
433
433
  const compactRuns = [];
434
+ // Gray runs are dropped on tight widths as decoration (ids, badges) —
435
+ // EXCEPT pure separators (" · "). Dropping those fuses adjacent values:
436
+ // "running · gpt-5.4 · ctx 33%" became "runninggpt-5.4ctx…".
437
+ const isSeparator = (run) => /^[\s·—-]+$/.test(String(run?.text || ""));
434
438
  let remaining = Math.max(8, maxWidth);
435
439
  for (const run of title) {
436
440
  if (remaining <= 0) break;
437
441
  const color = run?.color;
438
- if (color === "gray" && compactRuns.length > 0) continue;
442
+ if (color === "gray" && compactRuns.length > 0 && !isSeparator(run)) continue;
439
443
  const text = String(run?.text || "");
440
444
  if (!text) continue;
445
+ // Don't start a separator we can't follow with content.
446
+ if (isSeparator(run) && remaining < text.length + 2) break;
441
447
  const chunk = text.length > remaining && remaining > 1
442
448
  ? `${text.slice(0, remaining - 1)}…`
443
449
  : text.slice(0, remaining);
@@ -445,6 +451,10 @@ function compactTitleRuns(title, maxWidth = 40) {
445
451
  compactRuns.push({ ...run, text: chunk });
446
452
  remaining -= chunk.length;
447
453
  }
454
+ // Never end on a dangling separator.
455
+ while (compactRuns.length > 0 && isSeparator(compactRuns[compactRuns.length - 1])) {
456
+ compactRuns.pop();
457
+ }
448
458
  return compactRuns.length > 0 ? compactRuns : title;
449
459
  }
450
460
 
@@ -1715,6 +1725,7 @@ function PortalSequenceLines({ lines, theme, completionByTurn }) {
1715
1725
  );
1716
1726
  }
1717
1727
 
1728
+
1718
1729
  function ScrollLinesPanel({ title, titleRight = null, color, focused, actions, lines, stickyLines = [], bottomStickyLines = [], scrollOffset = 0, scrollMode = "top", paneKey, controller, className = "", panelClassName = "", topContent = null, bottomContent = null, structuredBlocks = false, stickyBottom = false, renderBody = null }) {
1719
1730
  const themeId = useControllerSelector(controller, (state) => state.ui.themeId);
1720
1731
  const theme = getTheme(themeId);
@@ -1736,11 +1747,21 @@ function ScrollLinesPanel({ title, titleRight = null, color, focused, actions, l
1736
1747
  });
1737
1748
  }, []);
1738
1749
 
1750
+ const [scrolledUp, setScrolledUp] = React.useState(false);
1751
+ const updateScrolledUp = React.useCallback((el) => {
1752
+ if (!el) return;
1753
+ const up = el.scrollHeight - el.scrollTop - el.clientHeight > 4;
1754
+ setScrolledUp((current) => (current === up ? current : up));
1755
+ }, []);
1739
1756
  const handleBodyScroll = React.useCallback((event) => {
1740
1757
  onScroll();
1758
+ updateScrolledUp(event.currentTarget);
1741
1759
  if (!preserveHorizontalScroll || syncingHorizontalRef.current) return;
1742
1760
  syncScrollLeft(event.currentTarget, stickyRef.current);
1743
- }, [onScroll, preserveHorizontalScroll, syncScrollLeft]);
1761
+ }, [onScroll, preserveHorizontalScroll, syncScrollLeft, updateScrolledUp]);
1762
+ React.useEffect(() => {
1763
+ updateScrolledUp(ref.current);
1764
+ });
1744
1765
 
1745
1766
  const handleStickyScroll = React.useCallback((event) => {
1746
1767
  if (!preserveHorizontalScroll || syncingHorizontalRef.current) return;
@@ -1758,7 +1779,7 @@ function ScrollLinesPanel({ title, titleRight = null, color, focused, actions, l
1758
1779
  normalizedSticky.map((line, index) => React.createElement(Line, { key: `sticky:${index}`, line, theme })),
1759
1780
  )
1760
1781
  : null,
1761
- React.createElement("div", { ref, className: `ps-scroll-panel ${className}`.trim(), onScroll: handleBodyScroll, onWheel, onTouchStart, onTouchMove, onTouchEnd, onTouchCancel: onTouchEnd },
1782
+ React.createElement("div", { ref, className: `ps-scroll-panel ${className}${scrolledUp ? " is-scrolled-up" : ""}`.trim(), onScroll: handleBodyScroll, onWheel, onTouchStart, onTouchMove, onTouchEnd, onTouchCancel: onTouchEnd },
1762
1783
  typeof renderBody === "function"
1763
1784
  ? renderBody(normalizedLines, theme)
1764
1785
  : structuredBlocks
@@ -2117,19 +2138,18 @@ function ChatPane({ controller, mobile = false, fullWidth = false, showComposer
2117
2138
  () => selectLiveActivityLines(selectorState, { spinnerFrame, maxWidth: viewState.contentWidth }),
2118
2139
  [selectorState, spinnerFrame, viewState.contentWidth],
2119
2140
  );
2120
- // Concatenate the isolated live-activity block onto the (expensive) message
2121
- // lines so the spinner tick only recomputes the small block, not the whole
2122
- // transcript. The block clears itself once the reply lands.
2123
- const chatLines = React.useMemo(
2124
- () => (liveActivityLines.length > 0
2125
- ? [...lines, [{ text: "", color: null }], ...liveActivityLines]
2126
- : lines),
2127
- [lines, liveActivityLines],
2128
- );
2141
+ // The live-activity line is pinned in the bottom-sticky strip (with the
2142
+ // outbox overlay), NOT appended to the transcript it must stay put while
2143
+ // chat content scrolls, and it drops the instant the turn ends.
2144
+ const pinnedActivityLines = liveActivityLines;
2129
2145
  const outboxLines = React.useMemo(
2130
2146
  () => selectOutboxOverlayLines(selectorState, viewState.contentWidth, { tableMode: "sentinel" }),
2131
2147
  [selectorState, viewState.contentWidth],
2132
2148
  );
2149
+ const stickyBottom = React.useMemo(
2150
+ () => (pinnedActivityLines.length > 0 ? [...outboxLines, ...pinnedActivityLines] : outboxLines),
2151
+ [outboxLines, pinnedActivityLines],
2152
+ );
2133
2153
  const composer = showComposer && !viewState.activeSessionIsGroup && viewState.chatViewMode !== "summary"
2134
2154
  ? React.createElement("div", { className: "ps-chat-composer" },
2135
2155
  React.createElement(PromptComposer, { controller, mobile, active: true }))
@@ -2144,8 +2164,8 @@ function ChatPane({ controller, mobile = false, fullWidth = false, showComposer
2144
2164
  actions: null,
2145
2165
  color: chrome.color,
2146
2166
  focused: viewState.focused,
2147
- lines: chatLines,
2148
- bottomStickyLines: outboxLines,
2167
+ lines,
2168
+ bottomStickyLines: stickyBottom,
2149
2169
  scrollOffset: viewState.scroll,
2150
2170
  scrollMode: "bottom",
2151
2171
  paneKey: "chat",
@@ -4105,11 +4125,27 @@ function AdminConsolePanel({ controller }) {
4105
4125
  : null,
4106
4126
  React.createElement("section", { className: "ps-admin-console__section" },
4107
4127
  React.createElement("h3", null, "GitHub Copilot key"),
4108
- React.createElement("p", { className: "ps-admin-console__hint" },
4109
- "Per-user override for the GitHub Copilot model provider token. ",
4110
- "When set, this key is used instead of the worker's env-supplied ",
4111
- "GITHUB_TOKEN for sessions you own. Clearing the key reverts to ",
4112
- "the worker default."),
4128
+ view.ghcpKey.storeAsSystem
4129
+ ? React.createElement("p", { className: "ps-admin-console__hint" },
4130
+ "System-wide key for platform-managed (ownerless) system ",
4131
+ "sessions agent tuners, repo cache managers, and other ",
4132
+ "sessions with no owner resolve this key for GitHub Copilot ",
4133
+ "models. Clearing it reverts them to the worker default.")
4134
+ : React.createElement("p", { className: "ps-admin-console__hint" },
4135
+ "Per-user override for the GitHub Copilot model provider token. ",
4136
+ "When set, this key is used instead of the worker's env-supplied ",
4137
+ "GITHUB_TOKEN for sessions you own. Clearing the key reverts to ",
4138
+ "the worker default."),
4139
+ view.isAdmin && view.systemGhcpKey.supported
4140
+ ? React.createElement("label", { className: "ps-admin-console__system-toggle" },
4141
+ React.createElement("input", {
4142
+ type: "checkbox",
4143
+ checked: view.ghcpKey.storeAsSystem,
4144
+ disabled: view.ghcpKey.saving,
4145
+ onChange: (event) => controller.setAdminGhcpKeyStoreAsSystem(event.target.checked),
4146
+ }),
4147
+ "Store as System key")
4148
+ : null,
4113
4149
  React.createElement("p", { className: `ps-admin-console__status${view.ghcpKey.error ? " is-error" : ""}` },
4114
4150
  view.ghcpKey.error || view.ghcpKey.statusText),
4115
4151
  view.ghcpKey.editing
@@ -4141,13 +4177,15 @@ function AdminConsolePanel({ controller }) {
4141
4177
  type: "button",
4142
4178
  className: "ps-primary-button",
4143
4179
  onClick: onBeginEdit,
4144
- }, view.ghcpKey.configured ? "Replace key" : "Set key"),
4145
- view.ghcpKey.configured
4180
+ }, view.ghcpKey.targetConfigured
4181
+ ? (view.ghcpKey.storeAsSystem ? "Replace System key" : "Replace key")
4182
+ : (view.ghcpKey.storeAsSystem ? "Set System key" : "Set key")),
4183
+ view.ghcpKey.targetConfigured
4146
4184
  ? React.createElement("button", {
4147
4185
  type: "button",
4148
4186
  className: "ps-mini-button",
4149
4187
  onClick: onClear,
4150
- }, "Clear key")
4188
+ }, view.ghcpKey.storeAsSystem ? "Clear System key" : "Clear key")
4151
4189
  : null,
4152
4190
  React.createElement("button", {
4153
4191
  type: "button",