openclacky 1.3.7 → 1.3.8

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 (36) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +24 -0
  3. data/lib/clacky/agent/hook_manager.rb +10 -1
  4. data/lib/clacky/agent/system_prompt_builder.rb +1 -1
  5. data/lib/clacky/agent.rb +9 -2
  6. data/lib/clacky/agent_profile.rb +7 -4
  7. data/lib/clacky/billing/billing_store.rb +3 -2
  8. data/lib/clacky/default_extensions/coding/agents/coding/avatar.png +0 -0
  9. data/lib/clacky/default_extensions/coding/ext.yml +4 -3
  10. data/lib/clacky/default_extensions/ext-studio/agents/ext-developer/avatar.png +0 -0
  11. data/lib/clacky/default_extensions/ext-studio/ext.yml +1 -0
  12. data/lib/clacky/default_extensions/ext-studio/panels/studio/view.js +24 -5
  13. data/lib/clacky/default_extensions/general/agents/general/avatar.png +0 -0
  14. data/lib/clacky/default_extensions/general/ext.yml +4 -3
  15. data/lib/clacky/default_extensions/git/panels/git/view.js +3 -3
  16. data/lib/clacky/default_extensions/meeting/panels/meeting/view.js +1 -2
  17. data/lib/clacky/extension/loader.rb +6 -0
  18. data/lib/clacky/extension/verifier.rb +1 -1
  19. data/lib/clacky/server/http_server.rb +129 -5
  20. data/lib/clacky/shell_hook_loader.rb +266 -22
  21. data/lib/clacky/ui2/components/welcome_banner.rb +1 -1
  22. data/lib/clacky/utils/workspace_rules.rb +2 -2
  23. data/lib/clacky/version.rb +1 -1
  24. data/lib/clacky/web/app.css +230 -46
  25. data/lib/clacky/web/core/ext.js +130 -24
  26. data/lib/clacky/web/features/extensions/view.js +4 -3
  27. data/lib/clacky/web/features/new-session/store.js +13 -0
  28. data/lib/clacky/web/features/new-session/view.js +314 -14
  29. data/lib/clacky/web/features/trash/view.js +4 -5
  30. data/lib/clacky/web/i18n.js +34 -0
  31. data/lib/clacky/web/index.html +43 -11
  32. data/lib/clacky/web/sessions.js +3 -3
  33. data/lib/clacky/web/skills.js +86 -48
  34. data/lib/clacky/web/theme.js +3 -0
  35. data/lib/clacky/web/ws-dispatcher.js +11 -2
  36. metadata +7 -7
@@ -221,6 +221,7 @@ html { font-size: 16px; }
221
221
  100% { background-position: -200% 0; }
222
222
  }
223
223
  .skel {
224
+ display: inline-block;
224
225
  border-radius: 6px;
225
226
  background: linear-gradient(90deg,
226
227
  #e8e8e8 25%,
@@ -1855,42 +1856,87 @@ body {
1855
1856
  /* ── New Session landing page (#new) ─────────────────────────────────────── */
1856
1857
  .new-session-page {
1857
1858
  justify-content: flex-start;
1858
- padding: 3rem 1.5rem 2rem;
1859
- overflow-y: auto;
1859
+ padding: 0;
1860
+ overflow: hidden;
1860
1861
  gap: 0;
1861
1862
  }
1862
1863
  .new-session-inner {
1863
1864
  width: 100%;
1864
- max-width: 44rem;
1865
+ height: 100%;
1865
1866
  display: flex;
1866
1867
  flex-direction: column;
1867
- gap: 1.5rem;
1868
+ gap: 0;
1869
+ }
1870
+ .new-session-body {
1871
+ flex: 1 1 auto;
1872
+ overflow-y: auto;
1873
+ display: flex;
1874
+ flex-direction: column;
1875
+ gap: 1.25rem;
1876
+ width: 100%;
1877
+ max-width: 60rem;
1878
+ margin: 0 auto;
1879
+ padding: 5.5rem 1.5rem 1rem;
1880
+ }
1881
+ .new-session-composer {
1882
+ position: relative;
1883
+ flex: none;
1884
+ padding: 0.75rem 1.5rem 1.25rem;
1868
1885
  }
1869
1886
  .new-session-title {
1870
1887
  font-size: 1.5rem;
1871
1888
  font-weight: 680;
1872
1889
  color: var(--color-text-primary);
1873
1890
  letter-spacing: -0.01em;
1874
- text-align: center;
1891
+ text-align: left;
1875
1892
  margin: 0;
1876
1893
  }
1877
1894
  .new-session-subtitle {
1878
1895
  color: var(--color-text-tertiary);
1879
1896
  font-size: 0.875rem;
1880
- text-align: center;
1897
+ text-align: left;
1881
1898
  margin: 0;
1882
1899
  }
1883
1900
  .new-session-agents {
1901
+ display: grid;
1902
+ grid-template-columns: repeat(auto-fill, minmax(13rem, 1fr));
1903
+ grid-auto-rows: 1fr;
1904
+ gap: 0.75rem;
1905
+ }
1906
+ .new-session-agent-panels {
1884
1907
  display: flex;
1885
1908
  flex-wrap: wrap;
1886
- gap: 0.75rem;
1887
- justify-content: flex-start;
1909
+ align-items: center;
1910
+ gap: 0.375rem;
1911
+ margin-top: -0.625rem;
1912
+ padding-left: 0.125rem;
1913
+ }
1914
+ .ns-panels-label {
1915
+ width: 100%;
1916
+ font-size: 0.75rem;
1917
+ color: var(--color-text-tertiary);
1918
+ margin-bottom: 0.0625rem;
1919
+ }
1920
+ .ns-panel-chip {
1921
+ display: inline-flex;
1922
+ align-items: center;
1923
+ padding: 0.1875rem 0.5rem;
1924
+ border-radius: 0.375rem;
1925
+ font-size: 0.78125rem;
1926
+ font-weight: 500;
1927
+ line-height: 1.3;
1928
+ }
1929
+ .ns-panel-chip--panel {
1930
+ background: var(--color-accent-soft);
1931
+ color: var(--color-accent-primary);
1932
+ }
1933
+ .ns-panel-chip--skill {
1934
+ background: var(--color-bg-secondary);
1935
+ color: var(--color-text-secondary);
1936
+ border: 1px solid var(--color-border-primary);
1888
1937
  }
1889
1938
  .agent-card {
1890
1939
  position: relative;
1891
- flex: 1 1 12rem;
1892
- min-width: 11rem;
1893
- max-width: 15rem;
1894
1940
  padding: 0.9375rem 1rem;
1895
1941
  border: 1px solid var(--color-border-primary);
1896
1942
  border-radius: 0.75rem;
@@ -1918,6 +1964,35 @@ body {
1918
1964
  align-items: center;
1919
1965
  gap: 0.375rem;
1920
1966
  }
1967
+ .agent-card-header {
1968
+ display: flex;
1969
+ align-items: center;
1970
+ gap: 0.625rem;
1971
+ margin-bottom: 0.125rem;
1972
+ }
1973
+ .agent-card-avatar {
1974
+ flex: none;
1975
+ width: 2.25rem;
1976
+ height: 2.25rem;
1977
+ border-radius: 50%;
1978
+ overflow: hidden;
1979
+ background: var(--color-bg-hover);
1980
+ display: flex;
1981
+ align-items: center;
1982
+ justify-content: center;
1983
+ }
1984
+ .agent-card-avatar img {
1985
+ width: 100%;
1986
+ height: 100%;
1987
+ object-fit: cover;
1988
+ display: block;
1989
+ }
1990
+ .agent-card-avatar--fallback {
1991
+ font-size: 1rem;
1992
+ font-weight: 700;
1993
+ color: var(--color-accent-primary);
1994
+ background: var(--color-accent-soft);
1995
+ }
1921
1996
  .agent-card-new {
1922
1997
  font-size: 0.6rem;
1923
1998
  font-weight: 700;
@@ -1951,45 +2026,77 @@ body {
1951
2026
  color: var(--color-accent-primary);
1952
2027
  }
1953
2028
  .agent-card-author {
2029
+ margin-top: 0.25rem;
1954
2030
  font-size: 0.72rem;
1955
2031
  color: var(--color-text-tertiary);
1956
2032
  opacity: 0.8;
1957
2033
  }
1958
2034
 
1959
- .new-session-composer {
1960
- display: flex;
1961
- flex-direction: column;
1962
- gap: 0.5rem;
2035
+ .new-session-input-bar {
2036
+ margin: 0;
2037
+ padding: 10px 12px;
1963
2038
  border: 1px solid var(--color-border-primary);
1964
- border-radius: 0.75rem;
1965
- padding: 0.75rem;
2039
+ border-radius: 13px;
1966
2040
  background: var(--color-bg-primary);
2041
+ display: flex;
2042
+ align-items: center;
2043
+ gap: 8px;
2044
+ box-shadow: var(--shadow-card);
2045
+ transition: border-color .14s;
2046
+ }
2047
+ .new-session-input-bar:focus-within {
2048
+ border-color: color-mix(in srgb, var(--color-accent-primary) 50%, var(--color-border-primary));
2049
+ }
2050
+ .new-session-composer-btn {
2051
+ flex: none;
2052
+ display: grid;
2053
+ place-items: center;
2054
+ width: 32px;
2055
+ height: 32px;
2056
+ padding: 0;
2057
+ border: none;
2058
+ border-radius: 8px;
2059
+ background: transparent;
2060
+ color: var(--color-text-tertiary);
2061
+ cursor: pointer;
2062
+ transition: background .12s, color .12s;
2063
+ }
2064
+ .new-session-composer-btn:hover {
2065
+ color: var(--color-text-primary);
2066
+ background: var(--color-bg-hover);
2067
+ }
2068
+ .new-session-composer-btn.active {
2069
+ color: var(--color-accent-primary);
2070
+ background: var(--color-accent-soft);
2071
+ }
2072
+ .new-session-composer-btn > span {
2073
+ font-size: 0.875rem;
2074
+ font-weight: 400;
2075
+ line-height: 1;
1967
2076
  }
1968
2077
  .new-session-textarea {
1969
- width: 100%;
2078
+ flex: 1;
1970
2079
  border: none;
1971
2080
  outline: none;
1972
- resize: vertical;
2081
+ resize: none;
1973
2082
  background: transparent;
1974
2083
  color: var(--color-text-primary);
1975
- font: inherit;
1976
- font-size: 0.9375rem;
1977
- line-height: 1.5;
1978
- padding: 0.25rem;
1979
- min-height: 3rem;
1980
- max-height: 12rem;
1981
- }
1982
- .new-session-composer-actions {
1983
- display: flex;
1984
- align-items: center;
1985
- justify-content: flex-end;
1986
- gap: 0.5rem;
2084
+ font-family: var(--font-sans);
2085
+ font-size: 0.875rem;
2086
+ line-height: 1.55;
2087
+ padding: 4px 6px;
2088
+ margin: 0;
2089
+ max-height: 12.5rem;
2090
+ overflow-y: auto;
2091
+ scrollbar-width: none;
1987
2092
  }
2093
+ .new-session-textarea::-webkit-scrollbar { display: none; }
1988
2094
  .new-session-advanced-wrap {
1989
2095
  display: flex;
1990
2096
  flex-direction: column;
1991
2097
  gap: 0.5rem;
1992
2098
  align-items: flex-start;
2099
+ margin-top: -0.75rem;
1993
2100
  }
1994
2101
  .new-session-advanced-toggle {
1995
2102
  display: inline-flex;
@@ -2006,7 +2113,6 @@ body {
2006
2113
  }
2007
2114
  .new-session-advanced-toggle:hover {
2008
2115
  color: var(--color-text-secondary);
2009
- background: var(--color-bg-hover);
2010
2116
  }
2011
2117
  .new-session-advanced-caret {
2012
2118
  transition: transform 0.15s;
@@ -2015,22 +2121,30 @@ body {
2015
2121
  transform: rotate(180deg);
2016
2122
  }
2017
2123
  .new-session-send {
2018
- padding: 0.4375rem 1rem;
2124
+ flex: none;
2125
+ display: grid;
2126
+ place-items: center;
2127
+ width: 34px;
2128
+ height: 34px;
2019
2129
  border: none;
2020
- border-radius: 0.5rem;
2130
+ border-radius: 9px;
2021
2131
  background: var(--color-accent-primary);
2022
- color: var(--color-text-inverse, #fff);
2023
- font-size: 0.875rem;
2024
- font-weight: 550;
2132
+ color: #fff;
2025
2133
  cursor: pointer;
2026
- transition: opacity 0.14s;
2134
+ transition: background .12s, opacity .12s;
2135
+ }
2136
+ .new-session-send svg {
2137
+ width: 15px;
2138
+ height: 15px;
2027
2139
  }
2028
2140
  .new-session-send:disabled {
2029
- opacity: 0.45;
2141
+ background: var(--color-border-primary);
2142
+ color: var(--color-text-tertiary);
2030
2143
  cursor: not-allowed;
2144
+ opacity: 0.6;
2031
2145
  }
2032
2146
  .new-session-send:not(:disabled):hover {
2033
- opacity: 0.9;
2147
+ background: color-mix(in srgb, var(--color-accent-primary) 85%, #fff);
2034
2148
  }
2035
2149
 
2036
2150
  .new-session-advanced {
@@ -2255,7 +2369,15 @@ body {
2255
2369
  .session-banner:empty,
2256
2370
  .session-composer:empty { display: none; }
2257
2371
  .session-banner { flex-shrink: 0; }
2258
- .session-composer { flex-shrink: 0; }
2372
+ .session-composer {
2373
+ flex-shrink: 0;
2374
+ margin-bottom: -9px;
2375
+ display: flex;
2376
+ padding-inline: 12px;
2377
+ }
2378
+ .session-composer > * {
2379
+ justify-content: flex-start !important;
2380
+ }
2259
2381
 
2260
2382
  /* ── File tree (used inside the Files tab) ──────────────────────────────── */
2261
2383
  .wt-panel { flex: 1; min-height: 0; display: flex; flex-direction: column; }
@@ -4038,6 +4160,12 @@ body {
4038
4160
  box-shadow: var(--shadow-card);
4039
4161
  margin-bottom: -1px;
4040
4162
  }
4163
+ #ns-image-preview-strip {
4164
+ display: flex;
4165
+ flex-wrap: wrap;
4166
+ gap: 0.5rem;
4167
+ padding: 0.25rem 0 0.5rem;
4168
+ }
4041
4169
  /* Square off the top corners of the input-bar when the preview strip sits above it */
4042
4170
  #input-area:has(#image-preview-strip:not([style*="display:none"])):not(:has(#image-preview-strip[style*="display: none"])) #input-bar {
4043
4171
  border-top-left-radius: 0;
@@ -4136,7 +4264,8 @@ body {
4136
4264
  .pdf-preview-remove:hover { background: var(--color-error); }
4137
4265
 
4138
4266
  /* ── Skill autocomplete dropdown ─────────────────────────────────────────── */
4139
- #skill-autocomplete {
4267
+ #skill-autocomplete,
4268
+ .skill-autocomplete {
4140
4269
  position: absolute;
4141
4270
  bottom: 100%;
4142
4271
  left: 0;
@@ -4154,7 +4283,8 @@ body {
4154
4283
  z-index: 1000;
4155
4284
  }
4156
4285
 
4157
- #skill-autocomplete-list {
4286
+ #skill-autocomplete-list,
4287
+ #ns-skill-autocomplete-list {
4158
4288
  padding: 0.25rem 0;
4159
4289
  }
4160
4290
 
@@ -6968,13 +7098,22 @@ body {
6968
7098
  border-color: var(--color-accent, #6366f1);
6969
7099
  }
6970
7100
  .extensions-sort {
7101
+ flex-shrink: 0;
6971
7102
  font-size: 0.8125rem;
6972
- padding: 0.4375rem 0.5rem;
7103
+ padding: 0.4375rem 2rem 0.4375rem 0.75rem;
6973
7104
  border-radius: 6px;
6974
7105
  border: 1px solid var(--color-border-primary);
6975
7106
  background: var(--color-bg-secondary);
6976
7107
  color: var(--color-text-primary);
6977
7108
  cursor: pointer;
7109
+ appearance: none;
7110
+ background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='6' viewBox='0 0 10 6'%3E%3Cpath d='M1 1l4 4 4-4' stroke='%23888' stroke-width='1.5' fill='none' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E");
7111
+ background-repeat: no-repeat;
7112
+ background-position: right 0.625rem center;
7113
+ }
7114
+ .extensions-sort:focus {
7115
+ outline: none;
7116
+ border-color: var(--color-accent, #6366f1);
6978
7117
  }
6979
7118
  #extensions-list {
6980
7119
  display: flex;
@@ -7065,14 +7204,23 @@ body {
7065
7204
  }
7066
7205
  .extension-detail-head { margin-bottom: 1rem; }
7067
7206
  .extension-detail-back {
7207
+ display: inline-flex;
7208
+ align-items: center;
7209
+ gap: 0.25rem;
7068
7210
  background: transparent;
7069
7211
  border: none;
7070
7212
  color: var(--color-text-secondary);
7071
7213
  font-size: 0.8125rem;
7072
7214
  cursor: pointer;
7073
- padding: 0.25rem 0;
7215
+ padding: 0.3rem 0.6rem;
7216
+ border-radius: 6px;
7217
+ margin-left: -0.6rem;
7218
+ transition: background 0.15s, color 0.15s;
7219
+ }
7220
+ .extension-detail-back:hover {
7221
+ color: var(--color-text-primary);
7222
+ background: var(--color-bg-hover);
7074
7223
  }
7075
- .extension-detail-back:hover { color: var(--color-text-primary); }
7076
7224
  .extension-detail-hero {
7077
7225
  display: flex;
7078
7226
  align-items: flex-start;
@@ -12472,5 +12620,41 @@ body.setup-mode[data-theme="dark"] {
12472
12620
  #workspace-overlay.active {
12473
12621
  display: block;
12474
12622
  }
12623
+
12624
+ /* ── New-session page: mobile ── */
12625
+ #welcome.new-session-page {
12626
+ position: fixed;
12627
+ top: 3rem;
12628
+ left: 0;
12629
+ right: 0;
12630
+ bottom: 0;
12631
+ overflow: hidden;
12632
+ padding: 0;
12633
+ justify-content: flex-start;
12634
+ height: auto;
12635
+ }
12636
+ .new-session-inner {
12637
+ height: 100%;
12638
+ display: flex;
12639
+ flex-direction: column;
12640
+ }
12641
+ .new-session-spacer {
12642
+ display: none;
12643
+ }
12644
+ .new-session-body {
12645
+ flex: 1 1 auto;
12646
+ min-height: 0;
12647
+ overflow-y: auto;
12648
+ -webkit-overflow-scrolling: touch;
12649
+ padding: 1.25rem 1rem 1rem;
12650
+ max-width: 100%;
12651
+ }
12652
+ .new-session-composer {
12653
+ flex: none;
12654
+ position: static;
12655
+ padding: 0.75rem 1rem 1rem;
12656
+ border-top: 1px solid var(--color-border-subtle);
12657
+ background: var(--color-bg-primary);
12658
+ }
12475
12659
  }
12476
12660
 
@@ -8,10 +8,16 @@
8
8
  // Clacky.ext.subscribe(event, handler) — listen to store events (read-only)
9
9
  // Clacky.ext.ui.mount(slot, spec, opts) — inject UI into a named slot
10
10
  //
11
- // `spec` is either a render function or { create?, render }. On per-session
12
- // slots (session.aside/banner/composer), providing `create(ctx)` gives you
13
- // one runtime per sessionId, isolated across sessions and disposed only
14
- // when the session leaves the sidebar.
11
+ // `spec` is either a render function or { create?, render }. The render
12
+ // signature is always `render(container, ctx, runtime)`:
13
+ // - `container` is a host-owned DOM element append your UI into it,
14
+ // or return a DOM node / HTML string and the host appends it for you.
15
+ // - returning a function registers it as a teardown callback.
16
+ // - returning null is treated as an error (almost always a wrong
17
+ // signature, e.g. `(ctx) => ...`), surfaced as a crashed placeholder.
18
+ // On per-session slots (session.aside/banner/composer), providing
19
+ // `create(ctx)` gives you one runtime per sessionId, isolated across
20
+ // sessions and disposed only when the session leaves the sidebar.
15
21
  //
16
22
  // Safety guarantees enforced here (the "constitution"):
17
23
  // - Every extension callback is wrapped in try/catch. A throwing extension is
@@ -49,8 +55,6 @@ Clacky.ext = (() => {
49
55
  // renderer becomes one tab (chrome drawn by the host), and only the active
50
56
  // tab's body is shown. Renderers in these slots must carry opts.tab.
51
57
  const TABBED_SLOTS = { "session.aside": true };
52
- // slot => active tab id (remembered across re-renders within a page load).
53
- const _activeTab = {};
54
58
 
55
59
  // Slots that follow the current session's agent scope. A mount into any
56
60
  // of these, made from inside a panel file, is confined to that panel's
@@ -92,6 +96,7 @@ Clacky.ext = (() => {
92
96
  // Resolved by the host before the extension scripts run (see loader markers).
93
97
  const _extAgents = {};
94
98
  const _panelAgents = {};
99
+ const _agentContributions = {}; // agent id => { panels:[{id,title,title_zh}], skills:[...] }
95
100
  let _currentPanel = null; // set while an official panel file is loading
96
101
 
97
102
  // Current session context, kept in sync by the host on every session switch.
@@ -213,6 +218,11 @@ Clacky.ext = (() => {
213
218
  Object.assign(_panelAgents, map);
214
219
  }
215
220
 
221
+ function registerAgentContributions(map) {
222
+ if (PURE || !map) return;
223
+ Object.assign(_agentContributions, map);
224
+ }
225
+
216
226
  const api = {
217
227
  // Register a named data source. Host/extensions can later resolve it.
218
228
  register(name, fn) {
@@ -376,26 +386,119 @@ Clacky.ext = (() => {
376
386
  // as the view teardown, run before the next render or on session exit.
377
387
  function _invokeRender(slot, entry, container, ctx) {
378
388
  const runtime = _getRuntime(slot, entry, ctx);
379
- let node, teardown, crashed = false;
389
+ let node, teardown, crashed = false, reason = null;
380
390
  try {
381
391
  const out = entry.render(container, ctx, runtime);
382
- if (out === undefined) {
383
- crashed = false; // container mutated in-place is fine
392
+ if (out === undefined || out === null) {
393
+ // undefined container mutated in-place; null → renderer opted out of
394
+ // this context (e.g. `if (!ctx.sessionId) return null` on the new-session
395
+ // page). Both render nothing; neither is an error.
384
396
  } else if (typeof out === "function") {
385
397
  teardown = out;
386
398
  } else {
387
399
  node = out;
388
400
  }
389
- } catch (_e) {
401
+ } catch (err) {
402
+ reason = String((err && err.stack) || err);
403
+ console.error(`[Clacky.ext] extension "${entry.extId || "?"}" threw in render for slot ${JSON.stringify(slot)}:`, err);
390
404
  crashed = true;
391
405
  }
392
- if (crashed) return { crashed: true };
406
+ if (crashed) return { crashed: true, reason, extId: entry.extId };
393
407
  if (ctx && ctx.sessionId && teardown) {
394
408
  _rememberViewTeardown(ctx.sessionId, _mountKey(slot, entry), teardown);
395
409
  }
396
410
  return { node };
397
411
  }
398
412
 
413
+ // Build a styled, self-contained crashed-panel placeholder: a short headline
414
+ // plus a collapsible <details> exposing the actual reason and a nudge to the
415
+ // console. Never throws; the reason is rendered as plain text.
416
+ function _crashedPlaceholder(extId, reason) {
417
+ _injectCrashStyle();
418
+ const box = document.createElement("div");
419
+ box.className = "ext-slot-crashed";
420
+ box.setAttribute("data-ext-status", "crashed");
421
+
422
+ const head = document.createElement("div");
423
+ head.className = "ext-crashed-head";
424
+ const icon = document.createElement("span");
425
+ icon.className = "ext-crashed-icon";
426
+ icon.textContent = "!";
427
+ const title = document.createElement("span");
428
+ title.className = "ext-crashed-title";
429
+ title.textContent = extId ? `扩展「${extId}」渲染失败` : "扩展渲染失败";
430
+ head.appendChild(icon);
431
+ head.appendChild(title);
432
+ box.appendChild(head);
433
+
434
+ const hint = document.createElement("div");
435
+ hint.className = "ext-crashed-hint";
436
+ hint.textContent = "面板未能加载。展开下方详情,或打开浏览器控制台 (console) 查看完整错误。";
437
+ box.appendChild(hint);
438
+
439
+ if (reason) {
440
+ const details = document.createElement("details");
441
+ details.className = "ext-crashed-details";
442
+ const summary = document.createElement("summary");
443
+ summary.textContent = "错误详情";
444
+ const pre = document.createElement("pre");
445
+ pre.className = "ext-crashed-reason";
446
+ pre.textContent = reason;
447
+ details.appendChild(summary);
448
+ details.appendChild(pre);
449
+ box.appendChild(details);
450
+ }
451
+ return box;
452
+ }
453
+
454
+ let _crashStyleInjected = false;
455
+ function _injectCrashStyle() {
456
+ if (_crashStyleInjected) return;
457
+ _crashStyleInjected = true;
458
+ const style = document.createElement("style");
459
+ style.id = "ext-crashed-style";
460
+ style.textContent = `
461
+ .ext-slot-crashed {
462
+ margin: 12px;
463
+ padding: 14px 16px;
464
+ border: 1px solid var(--color-error-border, #fecaca);
465
+ background: var(--color-error-bg, #fef2f2);
466
+ border-radius: var(--radius-md, 8px);
467
+ color: var(--color-text-secondary, #56585e);
468
+ font-size: 13px;
469
+ line-height: 1.5;
470
+ }
471
+ .ext-crashed-head { display: flex; align-items: center; gap: 8px; }
472
+ .ext-crashed-icon {
473
+ display: inline-flex; align-items: center; justify-content: center;
474
+ width: 18px; height: 18px; flex: none;
475
+ border-radius: 50%;
476
+ background: var(--color-error, #ef4444);
477
+ color: #fff; font-size: 12px; font-weight: 700; line-height: 1;
478
+ }
479
+ .ext-crashed-title { font-weight: 600; color: var(--color-text-primary, #1a1b1e); }
480
+ .ext-crashed-hint { margin-top: 6px; color: var(--color-text-tertiary, #8a8d94); }
481
+ .ext-crashed-details { margin-top: 10px; }
482
+ .ext-crashed-details > summary {
483
+ cursor: pointer; user-select: none;
484
+ color: var(--color-error, #ef4444); font-weight: 500;
485
+ }
486
+ .ext-crashed-reason {
487
+ margin: 8px 0 0;
488
+ padding: 10px;
489
+ max-height: 220px; overflow: auto;
490
+ background: var(--color-bg-primary, #fbfbfa);
491
+ border: 1px solid var(--color-border-primary, #e8e8e4);
492
+ border-radius: var(--radius-sm, 6px);
493
+ font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
494
+ font-size: 12px; line-height: 1.45;
495
+ white-space: pre-wrap; word-break: break-word;
496
+ color: var(--color-text-secondary, #56585e);
497
+ }
498
+ `;
499
+ (document.head || document.documentElement).appendChild(style);
500
+ }
501
+
399
502
 
400
503
  // null agents AND null panel => global (always visible). Otherwise the
401
504
  // current profile must be in the renderer's agent list, or in the set of
@@ -407,6 +510,13 @@ Clacky.ext = (() => {
407
510
  return false;
408
511
  }
409
512
 
513
+ // What a third-party agent's extension contributes (panels + skills), from
514
+ // ext.yml — so the new-session page can advertise it before a session exists.
515
+ // Returns { panels:[{id,title,title_zh}], skills:[...] } or null for built-ins.
516
+ function contributionsForAgent(agentId) {
517
+ return (agentId && _agentContributions[agentId]) || null;
518
+ }
519
+
410
520
  // Render every extension registered for `slot` into `container`, scoped to the
411
521
  // current agent profile. Called by the host's view layer wherever it exposes a
412
522
  // slot, and re-called on session switch (the container is cleared first so a
@@ -436,11 +546,9 @@ Clacky.ext = (() => {
436
546
  // Nav mounts that open a workspace declare it via opts.workspace; the
437
547
  // host stamps it so the Router alone owns the active highlight.
438
548
  if (entry.workspace) wrap.setAttribute("data-ext-workspace", entry.workspace);
439
- const { node, crashed } = _invokeRender(slot, entry, wrap, renderCtx);
549
+ const { node, crashed, reason, extId } = _invokeRender(slot, entry, wrap, renderCtx);
440
550
  if (crashed) {
441
- wrap.setAttribute("data-ext-status", "crashed");
442
- wrap.className = "ext-slot-crashed";
443
- wrap.textContent = "Extension failed to render.";
551
+ wrap.appendChild(_crashedPlaceholder(extId || entry.extId, reason));
444
552
  container.appendChild(wrap);
445
553
  return;
446
554
  }
@@ -478,17 +586,16 @@ Clacky.ext = (() => {
478
586
  const bodies = document.createElement("div");
479
587
  bodies.className = "aside-bodies";
480
588
 
481
- // Restore previously active tab if still present, else first tab.
589
+ // Always default to the first (highest-priority) tab. Tabs may register in
590
+ // several async passes; whichever pass runs last simply lands on tab #1.
482
591
  const ids = renderers.map((e) => e.tab.id);
483
- let active = _activeTab[slot];
484
- if (!active || !ids.includes(active)) active = ids[0];
592
+ const active = ids[0];
485
593
 
486
594
  const tabBtns = {};
487
595
  const bodyEls = {};
488
596
  const rendered = {};
489
597
 
490
598
  function activate(id) {
491
- _activeTab[slot] = id;
492
599
  Object.keys(tabBtns).forEach((k) => tabBtns[k].classList.toggle("active", k === id));
493
600
  Object.keys(bodyEls).forEach((k) => bodyEls[k].classList.toggle("active", k === id));
494
601
  if (!rendered[id]) {
@@ -498,12 +605,9 @@ Clacky.ext = (() => {
498
605
  const localCtx = Object.assign({}, renderCtx, {
499
606
  setBadge: (n) => _setTabBadge(tabBtns[id], n),
500
607
  });
501
- const { node, crashed } = _invokeRender(slot, entry, body, localCtx);
608
+ const { node, crashed, reason, extId } = _invokeRender(slot, entry, body, localCtx);
502
609
  if (crashed) {
503
- const ph = document.createElement("div");
504
- ph.className = "ext-slot-crashed";
505
- ph.textContent = "Extension failed to render.";
506
- body.appendChild(ph);
610
+ body.appendChild(_crashedPlaceholder(extId || entry.extId, reason));
507
611
  } else if (node != null) {
508
612
  if (typeof node === "string") body.innerHTML = node;
509
613
  else body.appendChild(node);
@@ -608,6 +712,8 @@ Clacky.ext = (() => {
608
712
  notifySessionRemoved,
609
713
  refreshSlots,
610
714
  registerPanelAgents,
715
+ registerAgentContributions,
716
+ contributionsForAgent,
611
717
  api,
612
718
  ui,
613
719
  subscribe,
@@ -21,8 +21,8 @@ const ExtensionsView = (() => {
21
21
  container.innerHTML = Array.from({ length: 4 }).map(() => `
22
22
  <div class="extension-card">
23
23
  <div class="extension-card-main">
24
- <span class="skel" style="height:2rem;width:2rem;border-radius:8px;"></span>
25
- <div class="extension-info">
24
+ <span class="skel" style="height:2rem;width:2rem;border-radius:8px;flex-shrink:0"></span>
25
+ <div class="extension-info" style="display:flex;flex-direction:column;gap:0.375rem">
26
26
  <span class="skel skel-title"></span>
27
27
  <span class="skel skel-subtitle"></span>
28
28
  </div>
@@ -161,7 +161,8 @@ const ExtensionsView = (() => {
161
161
  return `
162
162
  <div class="extension-detail-head">
163
163
  <button type="button" class="extension-detail-back" id="extension-detail-back">
164
- ${escapeHtml(I18n.t("extensions.detail.back"))}
164
+ <svg width="14" height="14" viewBox="0 0 14 14" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M9 2L4 7L9 12" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"/></svg>
165
+ ${escapeHtml(I18n.t("extensions.detail.back"))}
165
166
  </button>
166
167
  </div>
167
168
  ${inner}`;