openclacky 0.9.35 → 0.9.37

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.
@@ -147,6 +147,71 @@ module Clacky
147
147
  Clacky::Tools::Security.command_safe_for_auto_execution?(command)
148
148
  end
149
149
 
150
+ # ---------------------------------------------------------------------
151
+ # Internal Ruby API — synchronous capture
152
+ # ---------------------------------------------------------------------
153
+ #
154
+ # Run a shell command and BLOCK until it terminates, returning
155
+ # [output, exit_code]. Drop-in replacement for Open3.capture2e that
156
+ # goes through the same PTY + login-shell + Security pipeline used by
157
+ # the AI-facing tool (so rbenv/mise shims and gem mirrors work).
158
+ #
159
+ # Why this exists separately from #execute:
160
+ #
161
+ # `execute` may return early with a :session_id the moment output
162
+ # goes idle for DEFAULT_IDLE_MS (3s) — this is intentional for AI
163
+ # agents (they can inspect progress, inject input, decide to kill).
164
+ # Ruby callers like the HTTP server's upgrade flow only care about
165
+ # "did it finish, with what output, what exit code" — they need
166
+ # synchronous semantics. Previously each caller re-implemented the
167
+ # poll loop (and 0.9.36's run_shell forgot to, causing the upgrade
168
+ # failure bug).
169
+ #
170
+ # NOT exposed in tool_parameters — AI agents cannot invoke this.
171
+ #
172
+ # @param command [String] the shell command to run
173
+ # @param timeout [Integer] per-poll timeout AND the basis for the
174
+ # overall deadline (deadline = timeout + 60s)
175
+ # @param cwd [String] optional working directory
176
+ # @param env [Hash] optional env overrides
177
+ # @return [Array(String, Integer|nil)] [output, exit_code].
178
+ # exit_code is nil only if the overall deadline was hit and
179
+ # the session had to be force-killed.
180
+ def self.run_sync(command, timeout: 120, cwd: nil, env: nil)
181
+ terminal = new
182
+ result = terminal.execute(
183
+ command: command,
184
+ timeout: timeout,
185
+ cwd: cwd,
186
+ env: env,
187
+ )
188
+ output = result[:output].to_s
189
+
190
+ # Hard deadline in wall-clock terms — a genuinely stuck command
191
+ # must terminate. Each individual poll still carries `timeout`.
192
+ deadline = Time.now + timeout.to_i + 60
193
+
194
+ while result[:exit_code].nil? && result[:session_id] && Time.now < deadline
195
+ result = terminal.execute(
196
+ session_id: result[:session_id],
197
+ input: "",
198
+ timeout: timeout,
199
+ )
200
+ output += result[:output].to_s
201
+ end
202
+
203
+ # Deadline exceeded — best-effort cleanup so the session doesn't leak.
204
+ if result[:exit_code].nil? && result[:session_id]
205
+ begin
206
+ terminal.execute(session_id: result[:session_id], kill: true)
207
+ rescue StandardError
208
+ # swallow — cleanup is best-effort
209
+ end
210
+ end
211
+
212
+ [output, result[:exit_code]]
213
+ end
214
+
150
215
  # ---------------------------------------------------------------------
151
216
  # 1) Start a new command
152
217
  # ---------------------------------------------------------------------
@@ -274,7 +339,7 @@ module Clacky
274
339
  raw = read_log_slice(session.log_file, start_offset, new_offset)
275
340
  cleaned = OutputCleaner.clean(raw)
276
341
  cleaned = cleaned.sub(session.marker_regex, "").rstrip if session.marker_regex
277
- cleaned = strip_command_echo(cleaned)
342
+ cleaned = strip_command_echo(cleaned, marker_token: session.marker_token)
278
343
  truncated = false
279
344
  if cleaned.bytesize > MAX_LLM_OUTPUT_CHARS
280
345
  cleaned = cleaned.byteslice(0, MAX_LLM_OUTPUT_CHARS) + "\n...[output truncated]"
@@ -336,23 +401,97 @@ module Clacky
336
401
  # The shell may echo the wrapper line we injected (`{ USER_CMD; }; ...;
337
402
  # printf "__CLACKY_DONE_..."`) before running it. When stty -echo is
338
403
  # honoured (bash/fresh pty) this is a no-op; when it isn't (zsh ZLE
339
- # sometimes re-enables echo on reuse) we strip the wrapper echo.
404
+ # sometimes re-enables echo on reuse, or the user sent input to a
405
+ # running session) we strip the wrapper echo wherever it appears.
406
+ #
407
+ # Observed variants of the echoed wrapper:
408
+ #
409
+ # 1) Multi-line, starting the buffer (PTY in cooked mode, expanded
410
+ # \n escapes inside printf's double-quoted format string):
411
+ # { USER_CMD
412
+ # }; __clacky_ec=$?; printf "
413
+ # __CLACKY_DONE_<token>_%s__
414
+ # " "$__clacky_ec"
415
+ #
416
+ # 2) Single-line / partially-truncated (PTY width wrap or partial
417
+ # char drop ate the leading `{` or first chars of the command):
418
+ # ails runner foo.rb ... }; __clacky_ec=$?; printf " __CLACKY_DONE_<token>_%s__ " "$__clacky_ec"
340
419
  #
341
- # Note: when the PTY is in cooked mode and echoes the wrapper, the
342
- # terminal *interprets* the backslash-n escape pairs inside the
343
- # double-quoted printf format, so the wrapper echo spans multiple
344
- # real \n lines — not just two. We match lazily up to the closing
345
- # `"$__clacky_ec"` quote so we catch the entire echoed wrapper.
346
- private def strip_command_echo(text)
420
+ # 3) Embedded mid-stream when re-echoed (e.g. after session re-use
421
+ # or after a user input: call landed in a shell that re-enabled
422
+ # echo). Same shape as (1) or (2) but not anchored to the start.
423
+ #
424
+ # We handle all three by running two passes:
425
+ # * an anchored multi-line strip (keeps the legacy behaviour and is
426
+ # cheapest when stty -echo silently failed);
427
+ # * a token-aware global strip that removes any remaining echoed
428
+ # wrapper fragment anywhere in the buffer. The token makes this
429
+ # safe: the real completion marker was already removed via
430
+ # session.marker_regex above, so any surviving occurrence of
431
+ # __CLACKY_DONE_<token>_ is by definition an echoed wrapper.
432
+ private def strip_command_echo(text, marker_token: nil)
347
433
  return text if text.nil? || text.empty?
348
- # Match the whole echoed wrapper, however many lines the terminal
349
- # expanded its \n escapes into:
350
- # { USER_CMD
351
- # }; __clacky_ec=$?; printf "
352
- # __CLACKY_DONE_<token>_%s__
353
- # " "$__clacky_ec"
354
- # Anchored at the start; non-greedy across newlines via /m.
434
+
435
+ # Pass 1: anchored strip — the full wrapper echoed at the start,
436
+ # possibly spanning multiple real newlines.
355
437
  text = text.sub(/\A\{.*?"\$__clacky_ec"\s*\n?/m, "")
438
+
439
+ # Pass 2: token-aware global strip — remove any leftover wrapper
440
+ # echo fragment, wherever it sits. Requires the session token so
441
+ # we never touch unrelated user output that happens to mention
442
+ # `__clacky_ec`.
443
+ if marker_token && !marker_token.empty?
444
+ token_re = Regexp.escape(marker_token)
445
+
446
+ # 2a. Multi-line shape: walk back from __CLACKY_DONE_<token> to
447
+ # the opening `{` of the wrapper (start of line or start of
448
+ # buffer) and forward to the closing `"$__clacky_ec"`.
449
+ text = text.gsub(
450
+ /(?:^|(?<=\n))\{[^\n]*\n(?:[^\n]*\n)*?[^\n]*__CLACKY_DONE_#{token_re}_[^\n]*\n[^\n]*"\$__clacky_ec"[^\n]*\n?/,
451
+ ""
452
+ )
453
+
454
+ # 2b. Single-line shape: everything collapsed onto one line.
455
+ # Strip from the wrapper's `}; __clacky_ec=$?` pivot (or the
456
+ # opening `{` if still present on that line) through the end of
457
+ # the printf invocation (`"$__clacky_ec"`).
458
+ text = text.gsub(
459
+ /[^\n]*\}; *__clacky_ec=\$\?; *printf[^\n]*__CLACKY_DONE_#{token_re}_[^\n]*"\$__clacky_ec"[^\n]*\n?/,
460
+ ""
461
+ )
462
+
463
+ # 2c. Last-resort: a bare marker-format fragment on its own,
464
+ # without the `}; printf ...` prefix (e.g. terminal wrapped the
465
+ # echo such that only the tail survived). Drop lines that
466
+ # contain the literal `__CLACKY_DONE_<token>_%s__` format —
467
+ # the real marker has `\d+` in place of `%s` so this only hits
468
+ # echoed wrappers.
469
+ text = text.gsub(/^.*__CLACKY_DONE_#{token_re}_%s__.*\n?/, "")
470
+ end
471
+
472
+ # Pass 3: token-INDEPENDENT fingerprint strip — PTY width-wrap
473
+ # can chop the `__CLACKY_DONE_<token>_%s__` format string out of
474
+ # printf entirely, leaving e.g. `}; __clacky_ec=$?; printf " " "$__clacky_ec"`.
475
+ # None of the token-aware patterns above catch that. The pair
476
+ # `}; __clacky_ec=$?` (opening pivot) and `"$__clacky_ec"` (printf
477
+ # tail) are our wrapper's unique fingerprints — `__clacky_ec` is a
478
+ # private double-underscore var name that user code effectively
479
+ # never emits — so we strip anything between them (non-greedy,
480
+ # multiline-aware) to also handle width-wrap that inserted
481
+ # real \n breaks inside the echo.
482
+ text = text.gsub(
483
+ /[^\n]*\}; *__clacky_ec=\$\?.*?"\$__clacky_ec"[^\n]*\n?/m,
484
+ ""
485
+ )
486
+
487
+ # Pass 4: bare pivot with no printf tail at all (extreme
488
+ # truncation cut off everything after `__clacky_ec=$?`). Still a
489
+ # reliable fingerprint thanks to the `__clacky_ec` var name.
490
+ text = text.gsub(
491
+ /[^\n]*\}; *__clacky_ec=\$\?;?[^\n]*\n?/,
492
+ ""
493
+ )
494
+
356
495
  text
357
496
  end
358
497
 
@@ -1188,7 +1188,7 @@ module Clacky
1188
1188
  display_name = "#{type_badge}#{model_name} (#{masked_key})"
1189
1189
  choices << {
1190
1190
  name: display_name,
1191
- value: { action: :switch, index: idx }
1191
+ value: { action: :switch, model_id: model["id"] }
1192
1192
  }
1193
1193
  end
1194
1194
 
@@ -1210,8 +1210,15 @@ module Clacky
1210
1210
 
1211
1211
  case result[:action]
1212
1212
  when :switch
1213
- current_config.switch_model(result[:index])
1214
- # Auto-save after switching
1213
+ # CLI is a single-session context: when the user picks a model
1214
+ # we treat it as "make this my default from now on". So:
1215
+ # 1. switch this session's current model
1216
+ # 2. move the global `type: "default"` marker to it
1217
+ # 3. persist to config.yml so next CLI launch uses it
1218
+ # (Web UI's per-session switch is different — it must NOT do
1219
+ # steps 2 and 3, and uses switch_model_by_id directly.)
1220
+ current_config.switch_model_by_id(result[:model_id])
1221
+ current_config.set_default_model_by_id(result[:model_id])
1215
1222
  current_config.save
1216
1223
  # Return to indicate config changed (need to update client)
1217
1224
  return { action: :switch }
@@ -1228,10 +1235,12 @@ module Clacky
1228
1235
  base_url: new_model[:base_url],
1229
1236
  anthropic_format: anthropic_format
1230
1237
  )
1231
- # Auto-save after adding
1232
- current_config.save
1233
- # Set newly added model as default
1234
- current_config.switch_model(current_config.models.length - 1)
1238
+ # CLI: adding a model implies the user wants to use it now and
1239
+ # next launch. Switch this session to it AND set it as the
1240
+ # global default, then persist.
1241
+ new_id = current_config.models.last["id"]
1242
+ current_config.switch_model_by_id(new_id)
1243
+ current_config.set_default_model_by_id(new_id)
1235
1244
  current_config.save
1236
1245
  # Return to exit the menu
1237
1246
  return { action: :switch }
@@ -105,6 +105,42 @@ module Clacky
105
105
  }
106
106
  },
107
107
 
108
+ # DeepSeek V4 models
109
+ # Source: https://api-docs.deepseek.com/quick_start/pricing (USD / 1M tokens)
110
+ # DeepSeek billing model:
111
+ # - "cache miss input" = regular prompt_tokens rate
112
+ # - "cache hit input" = cache_read rate (DeepSeek has no separate cache-write charge)
113
+ # - No tiered pricing (single rate regardless of context length)
114
+ "deepseek-v4-flash" => {
115
+ input: {
116
+ default: 0.14, # $0.14/MTok cache miss
117
+ over_200k: 0.14 # no tiered pricing
118
+ },
119
+ output: {
120
+ default: 0.28, # $0.28/MTok
121
+ over_200k: 0.28
122
+ },
123
+ cache: {
124
+ write: 0.14, # DeepSeek doesn't charge extra for writes; bill at miss rate
125
+ read: 0.028 # $0.028/MTok cache hit
126
+ }
127
+ },
128
+
129
+ "deepseek-v4-pro" => {
130
+ input: {
131
+ default: 1.74, # $1.74/MTok cache miss
132
+ over_200k: 1.74
133
+ },
134
+ output: {
135
+ default: 3.48, # $3.48/MTok
136
+ over_200k: 3.48
137
+ },
138
+ cache: {
139
+ write: 1.74, # no separate write charge; bill at miss rate
140
+ read: 0.145 # $0.145/MTok cache hit
141
+ }
142
+ },
143
+
108
144
  # Default fallback pricing (conservative estimates)
109
145
  "default" => {
110
146
  input: {
@@ -238,6 +274,15 @@ module Clacky
238
274
  "claude-3-5-sonnet-20240620"
239
275
  when /claude-3-5-haiku-20241022/i
240
276
  "claude-3-5-haiku-20241022"
277
+ when /deepseek-v4-pro/i, /deepseek.*v4.*pro/i
278
+ "deepseek-v4-pro"
279
+ when /deepseek-v4-flash/i, /deepseek.*v4.*flash/i
280
+ "deepseek-v4-flash"
281
+ # Legacy aliases: deepseek-chat and deepseek-reasoner are being
282
+ # deprecated on 2026-07-24 and map to deepseek-v4-flash's
283
+ # non-thinking / thinking modes respectively. Bill at flash rates.
284
+ when /^deepseek-chat$/i, /^deepseek-reasoner$/i
285
+ "deepseek-v4-flash"
241
286
  else
242
287
  "default"
243
288
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Clacky
4
- VERSION = "0.9.35"
4
+ VERSION = "0.9.37"
5
5
  end
@@ -1359,144 +1359,36 @@ body {
1359
1359
 
1360
1360
  /* ── Chat panel ──────────────────────────────────────────────────────────── */
1361
1361
  #chat-panel { flex: 1; display: flex; flex-direction: column; overflow: hidden; position: relative; }
1362
- #chat-header {
1363
- padding: 10px 20px;
1364
- display: flex;
1365
- align-items: center;
1366
- gap: 10px;
1367
- min-height: 44px;
1368
- }
1369
- /* Title block — stacks the name + subtitle vertically so the header has
1370
- two lines of information (like Linear / Arc) without growing wider. */
1371
- #chat-title-block {
1372
- display: flex;
1373
- flex-direction: column;
1374
- min-width: 0; /* allow ellipsis inside */
1375
- flex: 1;
1376
- line-height: 1.25;
1377
- gap: 2px;
1378
- }
1379
- #chat-title {
1380
- font-weight: 600;
1381
- font-size: 15px;
1382
- color: var(--color-text-primary);
1383
- white-space: nowrap;
1384
- overflow: hidden;
1385
- text-overflow: ellipsis;
1386
- }
1387
- #chat-subtitle {
1388
- display: flex;
1389
- align-items: center;
1390
- gap: 6px;
1391
- font-size: 11px;
1392
- color: var(--color-text-secondary);
1393
- opacity: 0.75;
1394
- overflow: hidden;
1395
- white-space: nowrap;
1396
- text-overflow: ellipsis;
1397
- min-width: 0;
1398
- }
1399
- #chat-subtitle .chat-sub-source {
1400
- font-weight: 500;
1401
- color: var(--color-text-secondary);
1402
- flex-shrink: 0;
1403
- }
1404
- #chat-subtitle .chat-sub-dir {
1405
- font-family: var(--font-mono, monospace);
1406
- overflow: hidden;
1407
- text-overflow: ellipsis;
1408
- min-width: 0;
1409
- opacity: 0.85;
1410
- }
1411
- #chat-subtitle .chat-sub-sep {
1412
- opacity: 0.35;
1413
- flex-shrink: 0;
1414
- }
1415
-
1416
- /* Status badge styles - softed design */
1417
- .status-idle,
1418
- .status-running,
1419
- .status-error {
1420
- display: inline-flex;
1421
- align-items: center;
1422
- padding: 2px 8px;
1423
- border-radius: 4px;
1424
- font-size: 11px;
1425
- font-weight: 500;
1426
- text-transform: lowercase;
1427
- }
1428
1362
 
1429
- .status-idle {
1430
- background: var(--color-bg-secondary);
1431
- color: var(--color-text-secondary);
1432
- border: 1px solid var(--color-border-secondary);
1433
- }
1434
-
1435
- [data-theme="light"] .status-idle {
1436
- background: rgba(0, 0, 0, 0.04);
1437
- color: var(--color-text-secondary);
1438
- border: 1px solid rgba(0, 0, 0, 0.08);
1439
- }
1440
-
1441
- .status-running {
1442
- background: rgba(34, 197, 94, 0.1);
1443
- color: var(--color-success);
1444
- border: 1px solid rgba(34, 197, 94, 0.2);
1445
- }
1446
-
1447
- [data-theme="light"] .status-running {
1448
- background: rgba(34, 197, 94, 0.08);
1449
- color: #16a34a;
1450
- border: 1px solid rgba(34, 197, 94, 0.15);
1451
- }
1452
-
1453
- .status-error {
1454
- background: rgba(239, 68, 68, 0.1);
1455
- color: var(--color-error);
1456
- border: 1px solid rgba(239, 68, 68, 0.2);
1457
- }
1458
-
1459
- [data-theme="light"] .status-error {
1460
- background: rgba(239, 68, 68, 0.08);
1461
- color: #dc2626;
1462
- border: 1px solid rgba(239, 68, 68, 0.15);
1463
- }
1464
-
1465
- /* Delete session button in chat header */
1466
- /* Back button in chat header — hidden on desktop, shown on mobile via media query */
1363
+ /* Mobile-only floating back button — replaces the old in-header back button.
1364
+ Hidden on desktop; mobile media query enables it. Positioned absolutely so
1365
+ it doesn't take layout space or add visual chrome on desktop. */
1467
1366
  .chat-back-btn {
1468
1367
  display: none;
1368
+ }
1369
+ .chat-back-floating {
1370
+ position: absolute;
1371
+ top: 8px;
1372
+ left: 8px;
1373
+ z-index: 5;
1469
1374
  align-items: center;
1470
1375
  justify-content: center;
1471
1376
  width: 32px;
1472
1377
  height: 32px;
1473
- background: transparent;
1474
- border: none;
1378
+ background: var(--color-bg-primary);
1379
+ border: 1px solid var(--color-border-secondary);
1475
1380
  border-radius: 6px;
1476
1381
  color: var(--color-text-secondary);
1477
1382
  cursor: pointer;
1478
1383
  padding: 0;
1479
- flex-shrink: 0;
1480
1384
  transition: background .15s, color .15s;
1385
+ box-shadow: 0 1px 2px rgba(0, 0, 0, 0.04);
1481
1386
  }
1482
- .chat-back-btn:hover { background: var(--color-bg-hover); }
1387
+ .chat-back-floating:hover { background: var(--color-bg-hover); }
1483
1388
 
1484
- .btn-delete-session {
1485
- margin-left: auto;
1486
- display: flex;
1487
- align-items: center;
1488
- justify-content: center;
1489
- width: 28px;
1490
- height: 28px;
1491
- background: transparent;
1492
- border: none;
1493
- border-radius: 6px;
1494
- color: var(--color-text-tertiary);
1495
- cursor: pointer;
1496
- padding: 0;
1497
- transition: background .15s, color .15s;
1498
- }
1499
- .btn-delete-session:hover { background: var(--color-error-bg); color: var(--color-error); }
1389
+ /* Status badges (.status-idle/running/error) used to live in #chat-header.
1390
+ That header has been removed; the bottom #session-info-bar uses its own
1391
+ .sib-status-* classes instead. Legacy .status-* styles removed. */
1500
1392
 
1501
1393
  /* ── Messages ────────────────────────────────────────────────────────────── */
1502
1394
  #messages {
@@ -1508,6 +1400,61 @@ body {
1508
1400
  gap: 12px;
1509
1401
  }
1510
1402
 
1403
+ /* Empty-state hint: shown inside #messages when a session has no messages yet.
1404
+ Designed to be quiet and low-contrast so it guides without distracting. */
1405
+ .chat-empty-hint {
1406
+ margin: auto; /* vertical + horizontal centering inside flex column */
1407
+ max-width: 420px;
1408
+ padding: 24px 16px;
1409
+ display: flex;
1410
+ flex-direction: column;
1411
+ align-items: center;
1412
+ text-align: center;
1413
+ color: var(--color-text-secondary);
1414
+ user-select: none;
1415
+ pointer-events: none; /* purely decorative — don't block clicks/scroll */
1416
+ opacity: 0.85;
1417
+ }
1418
+ .chat-empty-hint .chat-empty-icon {
1419
+ color: var(--color-text-tertiary);
1420
+ margin-bottom: 14px;
1421
+ opacity: 0.55;
1422
+ }
1423
+ .chat-empty-hint .chat-empty-title {
1424
+ color: var(--color-text-primary);
1425
+ font-size: 15px;
1426
+ font-weight: 600;
1427
+ margin-bottom: 4px;
1428
+ }
1429
+ .chat-empty-hint .chat-empty-subtitle {
1430
+ color: var(--color-text-secondary);
1431
+ font-size: 13px;
1432
+ margin-bottom: 18px;
1433
+ opacity: 0.85;
1434
+ }
1435
+ .chat-empty-hint .chat-empty-tips {
1436
+ list-style: none;
1437
+ padding: 0;
1438
+ margin: 0;
1439
+ display: flex;
1440
+ flex-direction: column;
1441
+ gap: 6px;
1442
+ font-size: 12px;
1443
+ color: var(--color-text-tertiary);
1444
+ }
1445
+ .chat-empty-hint .chat-empty-tips li {
1446
+ position: relative;
1447
+ padding-left: 14px;
1448
+ line-height: 1.5;
1449
+ }
1450
+ .chat-empty-hint .chat-empty-tips li::before {
1451
+ content: "·";
1452
+ position: absolute;
1453
+ left: 4px;
1454
+ top: -1px;
1455
+ opacity: 0.6;
1456
+ }
1457
+
1511
1458
  /* New message notification banner */
1512
1459
  .new-message-banner {
1513
1460
  position: absolute;
@@ -5970,31 +5917,6 @@ body.setup-mode[data-theme="dark"] {
5970
5917
  padding: 0 12px;
5971
5918
  }
5972
5919
 
5973
- /* Chat sub-header: tighten padding */
5974
- #chat-header {
5975
- padding: 8px 12px;
5976
- gap: 8px;
5977
- }
5978
-
5979
- /* Truncate long session titles */
5980
- #chat-title {
5981
- font-size: 14px;
5982
- overflow: hidden;
5983
- text-overflow: ellipsis;
5984
- white-space: nowrap;
5985
- min-width: 0;
5986
- flex: 1;
5987
- }
5988
-
5989
- /* Status badge: smaller on mobile */
5990
- .status-idle,
5991
- .status-running,
5992
- .status-error {
5993
- font-size: 10px;
5994
- padding: 1px 6px;
5995
- flex-shrink: 0;
5996
- }
5997
-
5998
5920
  /* Session info bar: single-line, no hover-expand, font smaller */
5999
5921
  #session-info-bar {
6000
5922
  padding: 3px 12px;
@@ -6050,6 +5972,10 @@ body.setup-mode[data-theme="dark"] {
6050
5972
  .chat-back-btn {
6051
5973
  display: flex;
6052
5974
  }
5975
+ /* Reserve top space in messages so floating back button doesn't overlap content */
5976
+ #chat-panel #messages {
5977
+ padding-top: 52px;
5978
+ }
6053
5979
 
6054
5980
  /* Welcome page: vertically centered but shifted up, add horizontal padding */
6055
5981
  #welcome {
@@ -437,9 +437,7 @@ WS.onEvent(ev => {
437
437
  case "session_renamed": {
438
438
  Sessions.patch(ev.session_id, { name: ev.name });
439
439
  Sessions.renderList();
440
- if (ev.session_id === Sessions.activeId) {
441
- $("chat-title").textContent = ev.name;
442
- }
440
+ // Title is now shown only in the sidebar; chat-header element was removed.
443
441
  break;
444
442
  }
445
443
 
@@ -847,7 +845,7 @@ $("sidebar-overlay").addEventListener("click", _closeSidebar);
847
845
  // On mobile: start with sidebar hidden
848
846
  if (_isMobile()) _closeSidebar();
849
847
 
850
- // Mobile back button in chat-header: tap to go back to welcome
848
+ // Mobile floating back button (visible only on mobile via CSS): tap to go back to welcome.
851
849
  const _btnBack = document.querySelector(".chat-back-btn");
852
850
  if (_btnBack) _btnBack.addEventListener("click", () => Router.navigate("welcome"));
853
851
 
@@ -985,9 +983,14 @@ document.addEventListener("change", e => {
985
983
  if ($("theme-toggle-header")) {
986
984
  $("theme-toggle-header").addEventListener("click", () => Theme.toggle());
987
985
  }
988
- $("btn-delete-session").addEventListener("click", () => {
989
- if (Sessions.activeId) Sessions.deleteSession(Sessions.activeId);
990
- });
986
+ // btn-delete-session was removed with chat-header; deletion is now triggered from
987
+ // the bottom session-info-bar actions dropdown (see Sessions._showActionsMenu).
988
+ const _btnDeleteSession = $("btn-delete-session");
989
+ if (_btnDeleteSession) {
990
+ _btnDeleteSession.addEventListener("click", () => {
991
+ if (Sessions.activeId) Sessions.deleteSession(Sessions.activeId);
992
+ });
993
+ }
991
994
 
992
995
  // Load older history when the user scrolls to the top of the message list
993
996
  $("messages").addEventListener("scroll", () => {
@@ -1617,7 +1620,7 @@ window.bootAfterBrand = async function() {
1617
1620
  dropdown.innerHTML = "";
1618
1621
 
1619
1622
  models.forEach(m => {
1620
- console.log("[Model Switcher] Adding model:", m.model, "current:", currentModel);
1623
+ console.log("[Model Switcher] Adding model:", m.model, "id:", m.id, "current:", currentModel);
1621
1624
  const opt = document.createElement("div");
1622
1625
  opt.className = "sib-model-option";
1623
1626
  if (m.model === currentModel) opt.classList.add("current");
@@ -1633,7 +1636,8 @@ window.bootAfterBrand = async function() {
1633
1636
  opt.appendChild(badge);
1634
1637
  }
1635
1638
 
1636
- opt.addEventListener("click", () => _switchModel(sessionId, m.model));
1639
+ // Switch by id (stable across reorders/edits). Keep model name for UI update.
1640
+ opt.addEventListener("click", () => _switchModel(sessionId, m.id, m.model));
1637
1641
  dropdown.appendChild(opt);
1638
1642
  });
1639
1643
  console.log("[Model Switcher] Dropdown populated, children count:", dropdown.children.length);
@@ -1644,7 +1648,9 @@ window.bootAfterBrand = async function() {
1644
1648
  }
1645
1649
 
1646
1650
  // Switch session model via API
1647
- async function _switchModel(sessionId, newModel) {
1651
+ // modelId — stable runtime id (required by backend)
1652
+ // modelName — display name, used for optimistic UI update
1653
+ async function _switchModel(sessionId, modelId, modelName) {
1648
1654
  const dropdown = $("sib-model-dropdown");
1649
1655
  if (dropdown) {
1650
1656
  dropdown.style.display = "none";
@@ -1655,20 +1661,20 @@ window.bootAfterBrand = async function() {
1655
1661
  const res = await fetch(`/api/sessions/${sessionId}/model`, {
1656
1662
  method: "PATCH",
1657
1663
  headers: { "Content-Type": "application/json" },
1658
- body: JSON.stringify({ model: newModel })
1664
+ body: JSON.stringify({ model_id: modelId })
1659
1665
  });
1660
-
1666
+
1661
1667
  const data = await res.json();
1662
-
1668
+
1663
1669
  if (!res.ok) {
1664
1670
  throw new Error(data.error || "Unknown error");
1665
1671
  }
1666
-
1672
+
1667
1673
  // Update UI optimistically (will be confirmed by session_update broadcast)
1668
1674
  const sibModel = $("sib-model");
1669
- if (sibModel) sibModel.textContent = newModel;
1670
-
1671
- console.log(`Switched session ${sessionId} to model ${newModel}`);
1675
+ if (sibModel) sibModel.textContent = modelName;
1676
+
1677
+ console.log(`Switched session ${sessionId} to model ${modelName} (${modelId})`);
1672
1678
  } catch (e) {
1673
1679
  console.error("Failed to switch model:", e);
1674
1680
  alert("Failed to switch model: " + e.message);