tavern-harness 0.1.23 → 0.1.24

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -3,6 +3,52 @@
3
3
  > 本文件由 `scripts/changelog.mjs` 从 `changelog/v*.md` 生成,请勿手动编辑。
4
4
  > 版本对比链接见文末;源文件位于仓库 `changelog/` 目录。
5
5
 
6
+ ## [0.1.24] - 2026-10-27
7
+
8
+ ### 中文
9
+
10
+ #### 改进
11
+
12
+ - 工作区文件管理器现在可以直接进入 public 目录,并预览其中的网页、图片等文件
13
+ - 文件管理器的路径导航移到文件列表上方,窄屏下标题栏不再换行,右侧按钮也不会被挤掉
14
+ - 手机上点击顶部导航的「聊天 / 角色 / 成就 / 设置」或返回首页后,侧边栏会自动收起
15
+ - 手机端的工具调用卡片改用等宽字体并加粗,长工具名和参数更容易看清
16
+ - 输入框提示精简为「输入消息...」,不再把指令说明塞进占位文字
17
+ - 技能列表与原文编辑器的字体、名称截断和「内置」标签对齐统一
18
+ - 工具返回给模型的结果统一使用英文,切换界面语言不再影响模型读取;工具卡片中的结果行也随之显示英文
19
+
20
+ #### 修复
21
+
22
+ - 删除会话与会话设置保存改为事务写入,中途出错不会再留下只删了一半的数据
23
+ - 连续点击「删除」「保存」不会再重复提交,也不会卡在加载状态
24
+ - 创建、编辑、删除会话失败时会提示具体原因,而不是静默失败
25
+
26
+ #### 内部
27
+
28
+ - 清理工具执行路径中不再需要的界面文案依赖
29
+
30
+ ### English
31
+
32
+ #### Changed
33
+
34
+ - The workspace file manager can now browse into `public` and preview pages, images, and other files stored there
35
+ - The file manager's path breadcrumb now sits above the file list, and the header no longer wraps on narrow screens or pushes its buttons aside
36
+ - On mobile, tapping a top navigation tab (Chat, Characters, Achievements, Settings) or going home now closes the sidebar
37
+ - Tool call cards on mobile use a monospace, bolder typeface, so long tool names and arguments are easier to read
38
+ - The composer placeholder is now simply "Type a message..." instead of packing command hints into it
39
+ - The skill list and raw log editor now share consistent typography, name truncation, and Built-in tag alignment
40
+ - Tool results returned to the model are always English now, so switching the UI language no longer affects what the model reads; the result rows in tool cards show English as well
41
+
42
+ #### Fixed
43
+
44
+ - Deleting a session and saving session settings are now transactional, so a failure can no longer leave half-deleted data behind
45
+ - Repeatedly clicking delete or save no longer sends duplicate requests or leaves the dialog stuck loading
46
+ - Creating, editing, or deleting a session now reports the actual error instead of failing silently
47
+
48
+ #### Internal
49
+
50
+ - Removed the no-longer-needed UI string dependency from the tool execution path
51
+
6
52
  ## [0.1.23] - 2026-10-26
7
53
 
8
54
  ### 中文
@@ -656,6 +702,7 @@
656
702
  - Vite + React + Zustand + Dexie stack with browser-side local persistence
657
703
 
658
704
  <!-- 链接定义 -->
705
+ [0.1.24]: https://github.com/Rannichan/Tavern-Harness/compare/v0.1.23...v0.1.24
659
706
  [0.1.23]: https://github.com/Rannichan/Tavern-Harness/compare/v0.1.22...v0.1.23
660
707
  [0.1.22]: https://github.com/Rannichan/Tavern-Harness/compare/v0.1.21...v0.1.22
661
708
  [0.1.21]: https://github.com/Rannichan/Tavern-Harness/compare/v0.1.20...v0.1.21
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tavern-harness",
3
- "version": "0.1.23",
3
+ "version": "0.1.24",
4
4
  "description": "Tavern Harness — 酒馆模式下的本地 AI Agent 助手(MyAgent-Android 的 Web 复刻)",
5
5
  "private": false,
6
6
  "type": "module",
@@ -596,10 +596,11 @@ const server = createServer(async (req, res) => {
596
596
  const payload = JSON.parse(body);
597
597
  const base = resolveSessionBase(payload?.session);
598
598
  const rel = sanitizeWorkspaceRelativePath(payload?.path);
599
- if (resolvesOutsideSession(rel, base)) throw new Error('Path is outside the workspace');
599
+ const readingPublicLink = base !== PUBLIC_WORKSPACE && (rel === 'public' || rel.startsWith('public/'));
600
+ if (!readingPublicLink && resolvesOutsideSession(rel, base)) throw new Error('Path is outside the workspace');
600
601
  const file = resolve(base, rel);
601
602
  if (!existsSync(file) || !lstatSync(file).isFile()) throw new Error('File not found');
602
- assertInside(base, realpathSync(file));
603
+ assertInside(readingPublicLink ? PUBLIC_WORKSPACE : base, realpathSync(file));
603
604
  const content = await readFilePromise(file);
604
605
  if (content.length > 8 * 1024 * 1024) throw new Error('Preview file exceeds the 8MB limit');
605
606
  res.end(JSON.stringify({ ok: true, content: content.toString('base64') }));
package/src/App.tsx CHANGED
@@ -325,6 +325,7 @@ function SessionHeader({
325
325
  const [showEdit, setShowEdit] = useState(false);
326
326
  const [confirmReset, setConfirmReset] = useState(false);
327
327
  const [confirmDelete, setConfirmDelete] = useState(false);
328
+ const [deletingSession, setDeletingSession] = useState(false);
328
329
  const [exportOpen, setExportOpen] = useState(false);
329
330
  const [showWorkspace, setShowWorkspace] = useState(false);
330
331
  const [showMobileActions, setShowMobileActions] = useState(false);
@@ -482,10 +483,19 @@ function SessionHeader({
482
483
  <button className="btn" onClick={() => setConfirmDelete(false)}>{t('common.cancel')}</button>
483
484
  <button
484
485
  className="btn btn-danger"
486
+ disabled={deletingSession}
485
487
  onClick={async () => {
486
- await deleteSession(session.id!);
487
- setConfirmDelete(false);
488
- addToast(t('toast.sessionDeleted'));
488
+ if (deletingSession) return;
489
+ setDeletingSession(true);
490
+ try {
491
+ await deleteSession(session.id!);
492
+ setConfirmDelete(false);
493
+ addToast(t('toast.sessionDeleted'));
494
+ } catch (error) {
495
+ addToast(t('toast.sessionDeleteFailed', { msg: (error as Error).message }), 'error');
496
+ } finally {
497
+ setDeletingSession(false);
498
+ }
489
499
  }}
490
500
  >
491
501
  <Icon name="trash" size={13} /> {t('common.delete')}
@@ -556,7 +556,7 @@ function WorldBookList({ books, onChanged }: {
556
556
 
557
557
  return (
558
558
  <div>
559
- <div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 12 }}>
559
+ <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 12 }}>
560
560
  <span className="group-label" style={{ margin: 0 }}>{t('workshop.wbCount', { n: books.length })}</span>
561
561
  <div style={{ display: 'flex', gap: 8 }}>
562
562
  <button className="btn btn-sm" onClick={() => jsonFileRef.current?.click()} title={t('workshop.jsonImportTip')}>
@@ -963,8 +963,8 @@ function SkillSortItem({
963
963
  <div style={{ fontSize: 18 }}>{tool.isBuiltIn ? '🧰' : '⚙️'}</div>
964
964
  <div className="l-main">
965
965
  <div className="l-title">
966
- {tool.name}
967
- {tool.isBuiltIn && <span className="tag" style={{ marginLeft: 8 }}>{t('common.builtin')}</span>}
966
+ <span className="skill-card-name">{tool.name}</span>
967
+ {tool.isBuiltIn && <span className="tag">{t('common.builtin')}</span>}
968
968
  </div>
969
969
  <div className="l-sub">{desc}</div>
970
970
  {tool.isBuiltIn && impl !== 'native' && <div style={{ fontSize: 10.5, marginTop: 2, color: 'var(--warn)' }}>{t('workshop.implType', { t: impl })}</div>}
@@ -52,6 +52,7 @@ export function NewSessionMenu({ onClose, editingSession }: { onClose: () => voi
52
52
  const [turnOrderMode, setTurnOrderMode] = useState<'PRESET' | 'RANDOM'>(editingSession?.turnOrderMode ?? 'PRESET');
53
53
  const [mentionOnlyMode, setMentionOnlyMode] = useState(editingSession?.mentionOnlyMode === true);
54
54
  const [enableGreeting, setEnableGreeting] = useState(editingSession?.enableGreeting !== false);
55
+ const [saving, setSaving] = useState(false);
55
56
  const selectedNpcIds = participantOrder.filter((id) => id !== -1);
56
57
  const modeLabel = selectedNpcIds.length === 0 ? t('newSession.notSelected') : selectedNpcIds.length === 1 ? t('newSession.npcChat') : t('newSession.groupChat', { n: selectedNpcIds.length });
57
58
  const sensors = useSortableSensors();
@@ -77,55 +78,59 @@ export function NewSessionMenu({ onClose, editingSession }: { onClose: () => voi
77
78
  }, [editingSession?.id]);
78
79
 
79
80
  const create = async () => {
81
+ if (saving) return;
80
82
  if (selectedNpcIds.length === 0) {
81
83
  addToast(t('toast.pickOneNpc'), 'error');
82
84
  return;
83
85
  }
84
- const finalTitle = title.trim() || selectedNpcIds.map((id) => npcs.find((n) => n.id === id)?.name).filter(Boolean).join('、');
85
- if (isEditing && editingSession?.id) {
86
- await updateSessionSettings(editingSession.id, {
87
- title: finalTitle,
88
- npcIds: selectedNpcIds,
89
- worldBookId,
90
- userPersonaNpcId,
91
- turnOrderMode,
92
- participantOrder,
93
- mentionOnlyMode,
94
- enableGreeting,
95
- });
86
+ setSaving(true);
87
+ try {
88
+ const finalTitle = title.trim() || selectedNpcIds.map((id) => npcs.find((n) => n.id === id)?.name).filter(Boolean).join('、');
89
+ if (isEditing && editingSession?.id) {
90
+ await updateSessionSettings(editingSession.id, {
91
+ title: finalTitle,
92
+ npcIds: selectedNpcIds,
93
+ worldBookId,
94
+ userPersonaNpcId,
95
+ turnOrderMode,
96
+ participantOrder,
97
+ mentionOnlyMode,
98
+ enableGreeting,
99
+ });
100
+ addToast(t('toast.sessionUpdated'));
101
+ onClose();
102
+ return;
103
+ }
104
+ const sid = selectedNpcIds.length === 1
105
+ ? await createSession('NPC', {
106
+ associatedId: selectedNpcIds[0],
107
+ title: finalTitle,
108
+ worldBookId,
109
+ userPersonaNpcId,
110
+ turnOrderMode,
111
+ participantOrder,
112
+ mentionOnlyMode,
113
+ enableGreeting,
114
+ })
115
+ : await createSession('GROUP', {
116
+ npcIds: selectedNpcIds,
117
+ title: finalTitle,
118
+ worldBookId,
119
+ userPersonaNpcId,
120
+ turnOrderMode,
121
+ participantOrder,
122
+ mentionOnlyMode,
123
+ enableGreeting,
124
+ });
96
125
  await useStore.getState().refreshSessions();
97
- useStore.getState().setActiveSession(editingSession.id);
98
- addToast(t('toast.sessionUpdated'));
126
+ useStore.getState().setActiveSession(sid);
99
127
  onClose();
100
- return;
101
- }
102
- let sid: number;
103
- if (selectedNpcIds.length === 1) {
104
- sid = await createSession('NPC', {
105
- associatedId: selectedNpcIds[0],
106
- title: finalTitle,
107
- worldBookId,
108
- userPersonaNpcId,
109
- turnOrderMode,
110
- participantOrder,
111
- mentionOnlyMode,
112
- enableGreeting,
113
- });
114
- } else {
115
- sid = await createSession('GROUP', {
116
- npcIds: selectedNpcIds,
117
- title: finalTitle,
118
- worldBookId,
119
- userPersonaNpcId,
120
- turnOrderMode,
121
- participantOrder,
122
- mentionOnlyMode,
123
- enableGreeting,
124
- });
128
+ } catch (error) {
129
+ if (isEditing) addToast(t('toast.sessionSaveFailed', { msg: (error as Error).message }), 'error');
130
+ else addToast(t('toast.genFailed', { msg: (error as Error).message }), 'error');
131
+ } finally {
132
+ setSaving(false);
125
133
  }
126
- await useStore.getState().refreshSessions();
127
- useStore.getState().setActiveSession(sid);
128
- onClose();
129
134
  };
130
135
 
131
136
  const addParticipant = (npcId: number) => {
@@ -273,7 +278,7 @@ export function NewSessionMenu({ onClose, editingSession }: { onClose: () => voi
273
278
  </div>
274
279
  <div className="modal-foot">
275
280
  <button className="btn" onClick={onClose}>{t('common.cancel')}</button>
276
- <button className="btn btn-primary" disabled={!canCreate} onClick={create}>{isEditing ? t('common.save') : t('common.ok')}</button>
281
+ <button className="btn btn-primary" disabled={!canCreate || saving} onClick={create}>{isEditing ? t('common.save') : t('common.ok')}</button>
277
282
  </div>
278
283
  </Modal>
279
284
  );
@@ -62,6 +62,7 @@ export function Sidebar() {
62
62
 
63
63
  const [showNew, setShowNew] = useState(false);
64
64
  const [confirmDelete, setConfirmDelete] = useState<ChatSession | null>(null);
65
+ const [deletingSession, setDeletingSession] = useState(false);
65
66
  const [editTarget, setEditTarget] = useState<ChatSession | null>(null);
66
67
  const [sessionMenu, setSessionMenu] = useState<{ session: ChatSession; x: number; y: number } | null>(null);
67
68
  const [query, setQuery] = useState('');
@@ -178,11 +179,17 @@ export function Sidebar() {
178
179
  const npcById = (id: number | null) => (id != null ? npcs.find((n) => n.id === id) : null);
179
180
 
180
181
  const handleDelete = async () => {
181
- if (!confirmDelete) return;
182
- const sid = confirmDelete.id!;
183
- await deleteSession(sid);
184
- addToast(t('toast.sessionDeleted'));
185
- setConfirmDelete(null);
182
+ if (!confirmDelete || deletingSession) return;
183
+ setDeletingSession(true);
184
+ try {
185
+ await deleteSession(confirmDelete.id!);
186
+ addToast(t('toast.sessionDeleted'));
187
+ setConfirmDelete(null);
188
+ } catch (error) {
189
+ addToast(t('toast.sessionDeleteFailed', { msg: (error as Error).message }), 'error');
190
+ } finally {
191
+ setDeletingSession(false);
192
+ }
186
193
  };
187
194
 
188
195
  const openEdit = (session: ChatSession) => {
@@ -281,6 +288,7 @@ export function Sidebar() {
281
288
  }));
282
289
 
283
290
  const returnHome = () => {
291
+ setMobileOpen(false);
284
292
  const now = Date.now();
285
293
  homeClickTimes.current = [...homeClickTimes.current, now]
286
294
  .filter((time) => now - time <= SHAREHOLDER_CLICK_WINDOW_MS)
@@ -315,16 +323,16 @@ export function Sidebar() {
315
323
  >
316
324
  <img src="/tavern-harness-logo.png" alt="" />
317
325
  </button>
318
- <button className={`nav-chip ${activeView === 'chat' ? 'active' : ''}`} onClick={() => setActiveView('chat')}>
326
+ <button className={`nav-chip ${activeView === 'chat' ? 'active' : ''}`} onClick={() => { setActiveView('chat'); setMobileOpen(false); }}>
319
327
  <Icon name="send" size={15} /> <span>{t('nav.chat')}</span>
320
328
  </button>
321
- <button className={`nav-chip ${activeView === 'characters' ? 'active' : ''}`} onClick={() => setActiveView('characters')}>
329
+ <button className={`nav-chip ${activeView === 'characters' ? 'active' : ''}`} onClick={() => { setActiveView('characters'); setMobileOpen(false); }}>
322
330
  <Icon name="users" size={15} /> <span>{t('nav.characters')}</span>
323
331
  </button>
324
- <button className={`nav-chip ${activeView === 'stats' ? 'active' : ''}`} onClick={() => setActiveView('stats')}>
332
+ <button className={`nav-chip ${activeView === 'stats' ? 'active' : ''}`} onClick={() => { setActiveView('stats'); setMobileOpen(false); }}>
325
333
  <Icon name="trophy" size={15} /> <span>{t('nav.achievements')}</span>
326
334
  </button>
327
- <button className={`nav-chip ${activeView === 'settings' ? 'active' : ''}`} onClick={() => setActiveView('settings')}>
335
+ <button className={`nav-chip ${activeView === 'settings' ? 'active' : ''}`} onClick={() => { setActiveView('settings'); setMobileOpen(false); }}>
328
336
  <Icon name="settings" size={15} /> <span>{t('nav.settings')}</span>
329
337
  {hasUnseenVersion && <span className="unseen-dot" title={t('changelog.unseen')} />}
330
338
  </button>
@@ -531,7 +539,7 @@ export function Sidebar() {
531
539
  </div>
532
540
  <div className="modal-foot">
533
541
  <button className="btn" onClick={() => setConfirmDelete(null)}>{t('common.cancel')}</button>
534
- <button className="btn btn-danger" onClick={handleDelete}>
542
+ <button className="btn btn-danger" disabled={deletingSession} onClick={handleDelete}>
535
543
  <Icon name="trash" size={13} /> {t('common.delete')}
536
544
  </button>
537
545
  </div>
@@ -92,8 +92,9 @@ export function WorkspaceFileManagerModal({ sessionId, onClose }: { sessionId: n
92
92
  // 当前目录的直接子项(目录与文件同级)
93
93
  const visible = useMemo(() => {
94
94
  const directChild = (p: string) => {
95
- if (dir === '') return !p.replace(/\/$/, '').includes('/');
96
- return p.startsWith(dir) && p !== dir && !p.slice(dir.length).includes('/');
95
+ const normalized = p.replace(/\/$/, '');
96
+ if (dir === '') return !normalized.includes('/');
97
+ return normalized.startsWith(dir) && normalized !== dir.slice(0, -1) && !normalized.slice(dir.length).includes('/');
97
98
  };
98
99
  return nodes.filter((n) => directChild(n.path));
99
100
  }, [nodes, dir]);
@@ -154,23 +155,6 @@ export function WorkspaceFileManagerModal({ sessionId, onClose }: { sessionId: n
154
155
  <span className="ws-fm-title">
155
156
  <Icon name="folder" size={15} />
156
157
  {t('header.fileManagerTitle')}
157
- <span className="mono ws-fm-crumb">
158
- {crumbs.length === 0 ? rootLabel : (
159
- <>
160
- <span className="ws-fm-crumb-link" onClick={() => setDir('')}>{rootLabel}</span>
161
- {crumbs.map((c, i) => (
162
- <span key={i}>
163
- <span className="ws-fm-crumb-sep">/</span>
164
- {i === crumbs.length - 1 ? (
165
- <span className="ws-fm-crumb-cur">{c}</span>
166
- ) : (
167
- <span className="ws-fm-crumb-link" onClick={() => setDir(crumbs.slice(0, i + 1).join('/') + '/')}>{c}</span>
168
- )}
169
- </span>
170
- ))}
171
- </>
172
- )}
173
- </span>
174
158
  </span>
175
159
  <span className="ws-fm-head-actions">
176
160
  <button
@@ -186,8 +170,22 @@ export function WorkspaceFileManagerModal({ sessionId, onClose }: { sessionId: n
186
170
  </span>
187
171
  </div>
188
172
  <div className="modal-body ws-fm-body">
189
- <div className="ws-fm-note">
190
- <Icon name="eye" size={12} /> {t('header.fileManagerHint')}
173
+ <div className="mono ws-fm-crumb">
174
+ {crumbs.length === 0 ? rootLabel : (
175
+ <>
176
+ <span className="ws-fm-crumb-link" onClick={() => setDir('')}>{rootLabel}</span>
177
+ {crumbs.map((c, i) => (
178
+ <span key={i}>
179
+ <span className="ws-fm-crumb-sep">/</span>
180
+ {i === crumbs.length - 1 ? (
181
+ <span className="ws-fm-crumb-cur">{c}</span>
182
+ ) : (
183
+ <span className="ws-fm-crumb-link" onClick={() => setDir(crumbs.slice(0, i + 1).join('/') + '/')}>{c}</span>
184
+ )}
185
+ </span>
186
+ ))}
187
+ </>
188
+ )}
191
189
  </div>
192
190
  {loadState === 'loading' && (
193
191
  <div className="ws-fm-empty">
package/src/core/i18n.ts CHANGED
@@ -137,8 +137,8 @@ const MESSAGES: Record<LangKey, Record<string, string>> = {
137
137
  'chat.chooseModel': '选模型',
138
138
  'chat.selectModel': '选择模型',
139
139
  'chat.attachTip': '添加图片/视频(粘贴或点击)',
140
- 'chat.phGroup': '输入消息… 支持 @角色名 指定发言,/new 新话题,/pass 跳过',
141
- 'chat.phSingle': '输入消息… /new 开始新话题',
140
+ 'chat.phGroup': '输入消息...',
141
+ 'chat.phSingle': '输入消息...',
142
142
  'chat.mentionHead': '@ 点名角色',
143
143
  'chat.stopTip': '停止生成',
144
144
  'chat.sendTip': '发送',
@@ -442,6 +442,8 @@ const MESSAGES: Record<LangKey, Record<string, string>> = {
442
442
  // ---- 提示 ----
443
443
  'toast.sessionDeleted': '会话已删除',
444
444
  'toast.sessionUpdated': '会话设置已更新',
445
+ 'toast.sessionSaveFailed': '保存会话设置失败:{msg}',
446
+ 'toast.sessionDeleteFailed': '删除会话失败:{msg}',
445
447
  'toast.workspaceOpenLocalOnly': '仅可在运行 Tavern Harness 的设备上打开系统文件管理器',
446
448
  'toast.sessionReset': '会话已重置',
447
449
  'toast.sessionPinned': '已置顶对话',
@@ -662,8 +664,8 @@ const MESSAGES: Record<LangKey, Record<string, string>> = {
662
664
  'chat.chooseModel': '選模型',
663
665
  'chat.selectModel': '選擇模型',
664
666
  'chat.attachTip': '新增圖片/影片(貼上或點擊)',
665
- 'chat.phGroup': '輸入訊息… 支援 @角色名 指定發言,/new 新話題,/pass 跳過',
666
- 'chat.phSingle': '輸入訊息… /new 開始新話題',
667
+ 'chat.phGroup': '輸入訊息...',
668
+ 'chat.phSingle': '輸入訊息...',
667
669
  'chat.mentionHead': '@ 點名角色',
668
670
  'chat.stopTip': '停止產生',
669
671
  'chat.sendTip': '傳送',
@@ -960,6 +962,8 @@ const MESSAGES: Record<LangKey, Record<string, string>> = {
960
962
  'toast.gameplayImported': '已匯入遊戲「{title}」:新角色 {npc} 個、世界書 {wb}、技能 {tools} 個、對話 {msgs} 條',
961
963
  'toast.sessionDeleted': '會話已刪除',
962
964
  'toast.sessionUpdated': '會話設定已更新',
965
+ 'toast.sessionSaveFailed': '儲存會話設定失敗:{msg}',
966
+ 'toast.sessionDeleteFailed': '刪除會話失敗:{msg}',
963
967
  'toast.workspaceOpenLocalOnly': '僅可在執行 Tavern Harness 的裝置上開啟系統檔案管理員',
964
968
  'toast.sessionReset': '會話已重置',
965
969
  'toast.sessionPinned': '已置頂對話',
@@ -1176,8 +1180,8 @@ const MESSAGES: Record<LangKey, Record<string, string>> = {
1176
1180
  'chat.chooseModel': 'Model',
1177
1181
  'chat.selectModel': 'Select model',
1178
1182
  'chat.attachTip': 'Add image/video (paste or click)',
1179
- 'chat.phGroup': 'Type a message… use @name to address a character, /new for a new topic, /pass to skip',
1180
- 'chat.phSingle': 'Type a message… /new starts a new topic',
1183
+ 'chat.phGroup': 'Type a message...',
1184
+ 'chat.phSingle': 'Type a message...',
1181
1185
  'chat.mentionHead': '@ Mention a character',
1182
1186
  'chat.stopTip': 'Stop generating',
1183
1187
  'chat.sendTip': 'Send',
@@ -1472,6 +1476,8 @@ const MESSAGES: Record<LangKey, Record<string, string>> = {
1472
1476
 
1473
1477
  'toast.sessionDeleted': 'Session deleted',
1474
1478
  'toast.sessionUpdated': 'Conversation settings updated',
1479
+ 'toast.sessionSaveFailed': 'Failed to save conversation settings: {msg}',
1480
+ 'toast.sessionDeleteFailed': 'Failed to delete session: {msg}',
1475
1481
  'toast.workspaceOpenLocalOnly': 'The system file manager can only be opened on the device running Tavern Harness',
1476
1482
  'toast.sessionReset': 'Conversation reset',
1477
1483
  'toast.sessionPinned': 'Conversation pinned',
@@ -1,5 +1,3 @@
1
- import { translate } from '../i18n';
2
-
3
1
  // ============================================================
4
2
  // roll_dice — 与 App 的 DiceRoller 一致
5
3
  // ============================================================
@@ -28,11 +26,11 @@ export function rollDice(expression: string): string {
28
26
 
29
27
  let critical = '';
30
28
  if (count === 1 && sides === 20) {
31
- if (rolls[0] === 20) critical = translate('tool.diceCrit20');
32
- else if (rolls[0] === 1) critical = translate('tool.diceCrit1');
29
+ if (rolls[0] === 20) critical = ' Critical hit!';
30
+ else if (rolls[0] === 1) critical = ' Critical fail!';
33
31
  }
34
32
 
35
33
  const parts = [rolls.join(', ')];
36
34
  if (modifier !== 0) parts.push(`${modifier > 0 ? '+' : ''}${modifier}`);
37
- return translate('tool.diceRoll', { expr, rolls: parts.join('] '), total, crit: critical });
35
+ return `${expr}: [${parts.join('] ')}] = ${total}${critical}`;
38
36
  }
@@ -120,7 +120,7 @@ export async function executeGeneratedSkill(
120
120
  }
121
121
 
122
122
  export function truncateToolOutput(s: string): string {
123
- return s.length > MAX_OUTPUT_CHARS ? s.slice(0, MAX_OUTPUT_CHARS) + translate('tool.truncated') : s;
123
+ return s.length > MAX_OUTPUT_CHARS ? `${s.slice(0, MAX_OUTPUT_CHARS)}\n…(truncated)` : s;
124
124
  }
125
125
 
126
126
  function normalizePath(path: string): string {
@@ -398,7 +398,7 @@ async function diskFileWrite(
398
398
  });
399
399
  const data = (await resp.json()) as { ok?: boolean; message?: string };
400
400
  if (!resp.ok || !data.ok) throw new Error(data?.message || `HTTP ${resp.status}`);
401
- return translate('tool.fileWriteSuccess', { path, count: content.length });
401
+ return `OK: Wrote ${content.length} characters to ${path}`;
402
402
  } catch (e) {
403
403
  throw new Error(`Failed to write to disk: ${(e as Error).message}`);
404
404
  }
@@ -629,7 +629,7 @@ async function execShell(
629
629
  approved = false;
630
630
  }
631
631
  }
632
- if (!approved) return translate('tool.shellDenied', { name: 'shell' });
632
+ if (!approved) return 'CANCELLED: user rejected shell';
633
633
  const approvalError = await approveSandboxScript(confirmationRequestId!);
634
634
  if (approvalError) return approvalError;
635
635
  return await execSandboxScript(script, confirmationRequestId!, safeWorkspaceDir!);
@@ -8,7 +8,6 @@ import type {
8
8
  ToolConfirmationRequest,
9
9
  } from '../../types/models';
10
10
  import { BUILTIN_TOOL_NAMES } from '../toolDefinitions';
11
- import { translate } from '../i18n';
12
11
  import { unlockAchievement } from '../achievements';
13
12
  import { rollDice } from './builtinTools';
14
13
  import {
@@ -299,7 +298,7 @@ async function handleFileDisplay(args: Record<string, unknown>, ctx: ToolExecuti
299
298
  const payload: DisplayPayload = { path: rawPath, kind, title };
300
299
  // 第一行携带展示引用(store 解析并写入 displayRef / 自动弹窗)。
301
300
  // 不做内容摘要:完整内容在展示弹窗里,模型如需阅读应改用 file_read。
302
- return `${DISPLAY_REF_PREFIX}${JSON.stringify(payload)}\n${translate('tool.fileDisplaySuccess', { path: rawPath, kind })}`;
301
+ return `${DISPLAY_REF_PREFIX}${JSON.stringify(payload)}\nOK: Displayed ${rawPath} in a popup (kind=${kind})`;
303
302
  } catch (e) {
304
303
  return `ERROR: ${(e as Error).message}`;
305
304
  }
@@ -358,7 +357,7 @@ async function handleFileEdit(args: Record<string, unknown>, ctx: ToolExecutionC
358
357
  content.split(oldText).join(newText),
359
358
  workspaceDir,
360
359
  );
361
- return translate('tool.fileEditSuccess', { path, count: matches });
360
+ return `OK: Edited ${path}; replaced ${matches} occurrence(s)`;
362
361
  } catch (e) {
363
362
  return `ERROR: ${(e as Error).message}`;
364
363
  }
@@ -411,7 +410,7 @@ async function handleCreateSkill(args: Record<string, unknown>): Promise<string>
411
410
  const tool = await createSkillTool(name, String(args.description ?? ''), (args.parameters as Record<string, unknown>) ?? {}, args.execution);
412
411
  await db.tools.add(tool);
413
412
  await unlockAchievement('explorer');
414
- return translate('tool.skillCreated', { name });
413
+ return `OK: Created skill ${name}. Note: it is not enabled for any character by default; use update_character with enable_skills to enable it.`;
415
414
  }
416
415
 
417
416
  async function handleUpdateSkill(args: Record<string, unknown>): Promise<string> {
@@ -449,7 +448,7 @@ async function handleUpdateSkill(args: Record<string, unknown>): Promise<string>
449
448
  }
450
449
  }
451
450
  }
452
- return translate('tool.skillUpdated', { name });
451
+ return `OK: Updated skill ${name}`;
453
452
  }
454
453
 
455
454
  async function handleDeleteSkill(args: Record<string, unknown>): Promise<string> {
@@ -465,7 +464,7 @@ async function handleDeleteSkill(args: Record<string, unknown>): Promise<string>
465
464
  await db.npcs.update(n.id!, { enabledToolNames: n.enabledToolNames.filter((t) => t !== name) });
466
465
  }
467
466
  }
468
- return translate('tool.skillDeleted', { name });
467
+ return `OK: Deleted skill ${name}`;
469
468
  }
470
469
 
471
470
  // ---------- 角色 CRUD ----------
@@ -496,7 +495,7 @@ async function handleCreateCharacter(args: Record<string, unknown>): Promise<str
496
495
  createdAt: Date.now(),
497
496
  });
498
497
  await unlockAchievement('creator');
499
- return translate('tool.characterCreated', { name });
498
+ return `OK: Created character ${name}`;
500
499
  }
501
500
 
502
501
  async function handleUpdateCharacter(args: Record<string, unknown>): Promise<string> {
@@ -536,7 +535,7 @@ async function handleUpdateCharacter(args: Record<string, unknown>): Promise<str
536
535
  updates.enabledToolNames = [...enabled];
537
536
  }
538
537
  if (Object.keys(updates).length > 0) await db.npcs.update(npc.id!, updates);
539
- return translate('tool.characterUpdated', { name });
538
+ return `OK: Updated character ${name}`;
540
539
  }
541
540
 
542
541
  async function handleDeleteCharacter(args: Record<string, unknown>): Promise<string> {
@@ -551,7 +550,7 @@ async function handleDeleteCharacter(args: Record<string, unknown>): Promise<str
551
550
  await db.sessions.update(s.id!, { associatedId: null });
552
551
  await db.participants.where('[sessionId+participantId]').equals([s.id!, npc.id!]).delete();
553
552
  }
554
- return translate('tool.characterDeleted', { name });
553
+ return `OK: Deleted character ${name}`;
555
554
  }
556
555
 
557
556
  async function handleCreateConversation(args: Record<string, unknown>): Promise<string> {
@@ -642,7 +641,7 @@ async function handleCreateLorebook(args: Record<string, unknown>): Promise<stri
642
641
  if (await db.worldBooks.where('name').equals(name).first()) return `ERROR: Lorebook ${name} already exists`;
643
642
  await db.worldBooks.add({ name, content: content.slice(0, 10_000), imageUri: null, createdAt: Date.now() });
644
643
  await unlockAchievement('creator');
645
- return translate('tool.lorebookCreated', { name });
644
+ return `OK: Created Lorebook ${name}`;
646
645
  }
647
646
 
648
647
  async function handleUpdateLorebook(args: Record<string, unknown>): Promise<string> {
@@ -651,25 +650,25 @@ async function handleUpdateLorebook(args: Record<string, unknown>): Promise<stri
651
650
  if (!book) return `ERROR: Lorebook ${name} does not exist`;
652
651
  const updates: Partial<import('../../types/models').WorldBook> = {};
653
652
  if (args.new_name) {
654
- if (isBuiltinTavernHandbook(book)) return `ERROR: 内置世界书「${builtinTavernHandbookName()}」名称不可修改`;
653
+ if (isBuiltinTavernHandbook(book)) return `ERROR: Built-in Lorebook ${builtinTavernHandbookName()} cannot be renamed`;
655
654
  updates.name = String(args.new_name).slice(0, 60);
656
655
  }
657
656
  if (args.content != null) updates.content = String(args.content).slice(0, 10_000);
658
657
  await db.worldBooks.update(book.id!, updates);
659
- return translate('tool.lorebookUpdated', { name });
658
+ return `OK: Updated Lorebook ${name}`;
660
659
  }
661
660
 
662
661
  async function handleDeleteLorebook(args: Record<string, unknown>): Promise<string> {
663
662
  const name = String(args.name ?? '');
664
663
  const book = await db.worldBooks.where('name').equals(name).first();
665
664
  if (!book) return `ERROR: Lorebook ${name} does not exist`;
666
- if (isBuiltinTavernHandbook(book)) return `ERROR: 内置世界书「${builtinTavernHandbookName()}」不可删除`;
665
+ if (isBuiltinTavernHandbook(book)) return `ERROR: Built-in Lorebook ${builtinTavernHandbookName()} cannot be deleted`;
667
666
  await db.worldBooks.delete(book.id!);
668
667
  const sessions = await db.sessions.toArray();
669
668
  for (const s of sessions) {
670
669
  if (s.worldBookId === book.id) await db.sessions.update(s.id!, { worldBookId: null });
671
670
  }
672
- return translate('tool.lorebookDeleted', { name });
671
+ return `OK: Deleted Lorebook ${name}`;
673
672
  }
674
673
 
675
674
  // ---------- 工具列举 ----------
@@ -1,7 +1,71 @@
1
1
  {
2
- "latest": "0.1.23",
2
+ "latest": "0.1.24",
3
3
  "repo": "https://github.com/Rannichan/Tavern-Harness",
4
4
  "entries": [
5
+ {
6
+ "version": "0.1.24",
7
+ "date": "2026-10-27",
8
+ "breaking": false,
9
+ "compare": "v0.1.23...v0.1.24",
10
+ "groups": {
11
+ "zh-CN": [
12
+ {
13
+ "kind": "changed",
14
+ "items": [
15
+ "工作区文件管理器现在可以直接进入 public 目录,并预览其中的网页、图片等文件",
16
+ "文件管理器的路径导航移到文件列表上方,窄屏下标题栏不再换行,右侧按钮也不会被挤掉",
17
+ "手机上点击顶部导航的「聊天 / 角色 / 成就 / 设置」或返回首页后,侧边栏会自动收起",
18
+ "手机端的工具调用卡片改用等宽字体并加粗,长工具名和参数更容易看清",
19
+ "输入框提示精简为「输入消息...」,不再把指令说明塞进占位文字",
20
+ "技能列表与原文编辑器的字体、名称截断和「内置」标签对齐统一",
21
+ "工具返回给模型的结果统一使用英文,切换界面语言不再影响模型读取;工具卡片中的结果行也随之显示英文"
22
+ ]
23
+ },
24
+ {
25
+ "kind": "fixed",
26
+ "items": [
27
+ "删除会话与会话设置保存改为事务写入,中途出错不会再留下只删了一半的数据",
28
+ "连续点击「删除」「保存」不会再重复提交,也不会卡在加载状态",
29
+ "创建、编辑、删除会话失败时会提示具体原因,而不是静默失败"
30
+ ]
31
+ },
32
+ {
33
+ "kind": "internal",
34
+ "items": [
35
+ "清理工具执行路径中不再需要的界面文案依赖"
36
+ ]
37
+ }
38
+ ],
39
+ "en": [
40
+ {
41
+ "kind": "changed",
42
+ "items": [
43
+ "The workspace file manager can now browse into `public` and preview pages, images, and other files stored there",
44
+ "The file manager's path breadcrumb now sits above the file list, and the header no longer wraps on narrow screens or pushes its buttons aside",
45
+ "On mobile, tapping a top navigation tab (Chat, Characters, Achievements, Settings) or going home now closes the sidebar",
46
+ "Tool call cards on mobile use a monospace, bolder typeface, so long tool names and arguments are easier to read",
47
+ "The composer placeholder is now simply \"Type a message...\" instead of packing command hints into it",
48
+ "The skill list and raw log editor now share consistent typography, name truncation, and Built-in tag alignment",
49
+ "Tool results returned to the model are always English now, so switching the UI language no longer affects what the model reads; the result rows in tool cards show English as well"
50
+ ]
51
+ },
52
+ {
53
+ "kind": "fixed",
54
+ "items": [
55
+ "Deleting a session and saving session settings are now transactional, so a failure can no longer leave half-deleted data behind",
56
+ "Repeatedly clicking delete or save no longer sends duplicate requests or leaves the dialog stuck loading",
57
+ "Creating, editing, or deleting a session now reports the actual error instead of failing silently"
58
+ ]
59
+ },
60
+ {
61
+ "kind": "internal",
62
+ "items": [
63
+ "Removed the no-longer-needed UI string dependency from the tool execution path"
64
+ ]
65
+ }
66
+ ]
67
+ }
68
+ },
5
69
  {
6
70
  "version": "0.1.23",
7
71
  "date": "2026-10-26",
@@ -907,13 +907,20 @@ export const useStore = create<AppState>((set, get) => ({
907
907
  },
908
908
 
909
909
  deleteSession: async (id) => {
910
- // 先取会话记录(工作目录在删除后不可查),再清理目录与数据
911
- const session = await db.sessions.get(id);
912
910
  invalidateContextCompressionJob(id);
913
- await db.messages.where('sessionId').equals(id).delete();
914
- await db.contextCompressions.where('sessionId').equals(id).delete();
915
- await db.participants.where('sessionId').equals(id).delete();
916
- await db.sessions.delete(id);
911
+ const session = await db.transaction(
912
+ 'rw',
913
+ [db.sessions, db.messages, db.contextCompressions, db.participants],
914
+ async () => {
915
+ // 先取会话记录(工作目录在删除后不可查),再原子删除关联数据。
916
+ const current = await db.sessions.get(id);
917
+ await db.messages.where('sessionId').equals(id).delete();
918
+ await db.contextCompressions.where('sessionId').equals(id).delete();
919
+ await db.participants.where('sessionId').equals(id).delete();
920
+ await db.sessions.delete(id);
921
+ return current;
922
+ },
923
+ );
917
924
  // 删除会话的专属磁盘工作目录;沙箱不可用时静默跳过
918
925
  if (session?.workspaceDir) {
919
926
  await deleteSessionWorkspace(session.workspaceDir);
@@ -1047,7 +1054,10 @@ export const useStore = create<AppState>((set, get) => ({
1047
1054
  const npcIds = [...new Set((opts.npcIds ?? []).filter((id) => Number.isFinite(id)).map(Number))].slice(0, 5);
1048
1055
  if (npcIds.length === 0) return;
1049
1056
  const participantOrder = opts.participantOrder ?? [-1, ...npcIds];
1050
- const participants = await buildSessionParticipants(sessionId, npcIds, participantOrder);
1057
+ const [participants, existingParticipants] = await Promise.all([
1058
+ buildSessionParticipants(sessionId, npcIds, participantOrder),
1059
+ db.participants.where('sessionId').equals(sessionId).toArray(),
1060
+ ]);
1051
1061
  const finalNpcIds = participants.filter((p) => p.kind === 'NPC' && p.npcId != null).map((p) => p.npcId!);
1052
1062
  if (finalNpcIds.length === 0) return;
1053
1063
  const finalTitle = opts.title?.trim() || participants
@@ -1055,8 +1065,7 @@ export const useStore = create<AppState>((set, get) => ({
1055
1065
  .map((p) => p.displayName)
1056
1066
  .join('、');
1057
1067
  const turnOrderMode = opts.turnOrderMode === 'RANDOM' ? 'RANDOM' : 'PRESET';
1058
- const existingParticipants = (await db.participants.where('sessionId').equals(sessionId).toArray())
1059
- .sort((left, right) => left.seatOrder - right.seatOrder);
1068
+ existingParticipants.sort((left, right) => left.seatOrder - right.seatOrder);
1060
1069
  const queueSettingsChanged = turnOrderMode !== session.turnOrderMode
1061
1070
  || existingParticipants.length !== participants.length
1062
1071
  || existingParticipants.some((existing, index) => {
@@ -1068,29 +1077,30 @@ export const useStore = create<AppState>((set, get) => ({
1068
1077
  || existing.seatOrder !== next.seatOrder;
1069
1078
  });
1070
1079
  const queue = queueSettingsChanged ? initializeTurnQueue(participants, turnOrderMode) : safeParseQueue(session.turnQueueJson);
1071
- if (queueSettingsChanged) {
1072
- await db.participants.where('sessionId').equals(sessionId).delete();
1073
- await db.participants.bulkAdd(participants);
1074
- }
1075
- await db.sessions.update(sessionId, {
1076
- title: finalTitle,
1077
- mode: finalNpcIds.length === 1 ? 'NPC' : 'GROUP',
1078
- associatedId: finalNpcIds.length === 1 ? finalNpcIds[0] : null,
1079
- worldBookId: opts.worldBookId ?? null,
1080
- userPersonaNpcId: opts.userPersonaNpcId ?? null,
1081
- enableGreeting: opts.enableGreeting !== false,
1082
- mentionOnlyMode: opts.mentionOnlyMode === true && finalNpcIds.length > 1,
1083
- turnOrderMode,
1084
- ...(queueSettingsChanged ? {
1085
- turnQueueJson: queueJson(queue),
1086
- turnQueueHistoryJson: queueHistoryJson([queue]),
1087
- loopIndex: 0,
1088
- } : {}),
1089
- updatedAt: Date.now(),
1080
+ await db.transaction('rw', db.sessions, db.participants, async () => {
1081
+ if (queueSettingsChanged) {
1082
+ await db.participants.where('sessionId').equals(sessionId).delete();
1083
+ await db.participants.bulkAdd(participants);
1084
+ }
1085
+ await db.sessions.update(sessionId, {
1086
+ title: finalTitle,
1087
+ mode: finalNpcIds.length === 1 ? 'NPC' : 'GROUP',
1088
+ associatedId: finalNpcIds.length === 1 ? finalNpcIds[0] : null,
1089
+ worldBookId: opts.worldBookId ?? null,
1090
+ userPersonaNpcId: opts.userPersonaNpcId ?? null,
1091
+ enableGreeting: opts.enableGreeting !== false,
1092
+ mentionOnlyMode: opts.mentionOnlyMode === true && finalNpcIds.length > 1,
1093
+ turnOrderMode,
1094
+ ...(queueSettingsChanged ? {
1095
+ turnQueueJson: queueJson(queue),
1096
+ turnQueueHistoryJson: queueHistoryJson([queue]),
1097
+ loopIndex: 0,
1098
+ } : {}),
1099
+ updatedAt: Date.now(),
1100
+ });
1090
1101
  });
1091
- await get().loadMessages(sessionId);
1092
1102
  await get().refreshSessions();
1093
- await get().refreshLiveQueue(sessionId);
1103
+ if (queueSettingsChanged) await get().refreshLiveQueue(sessionId);
1094
1104
  },
1095
1105
 
1096
1106
  resetSessionConversation: async (sessionId) => {
@@ -2268,7 +2278,7 @@ async function performAssistantTurn(
2268
2278
  approved = await requestToolConfirmation(sessionId, tc);
2269
2279
  }
2270
2280
  if (!approved) {
2271
- result = translate('toast.canceled', { name: tc.name });
2281
+ result = `CANCELLED: user rejected ${tc.name}`;
2272
2282
  } else {
2273
2283
  result = await executeToolCall(tc.name, tc.argumentsJson, {
2274
2284
  sessionId,
@@ -2283,7 +2293,7 @@ async function performAssistantTurn(
2283
2293
  }
2284
2294
  // 工具没有返回任何内容(空模板 / 空文件 / 空输出等)→ 补占位结果,
2285
2295
  // 保证调用卡片立即显示「无结果」而不是空白
2286
- if (!result || !result.trim()) result = translate('tool.noResult');
2296
+ if (!result || !result.trim()) result = '(no result)';
2287
2297
  // 数据变更类工具执行后即时刷新 store,保证界面(角色工坊等)无需刷新即可看到最新数据
2288
2298
  if (result.startsWith('OK:')) {
2289
2299
  if (tc.name.includes('character')) await useStore.getState().refreshNpcs();
@@ -271,9 +271,9 @@
271
271
  .tool-card-name {
272
272
  flex: 1;
273
273
  min-width: 0;
274
- /* 工具名走等宽字体:和对话正文(衬线/无衬线)形成“终端 vs 消息”的区别 */
275
274
  font-family: var(--font-mono);
276
275
  font-size: 12px;
276
+ font-weight: 600;
277
277
  letter-spacing: -0.1px;
278
278
  white-space: nowrap;
279
279
  overflow: hidden;
@@ -282,8 +282,11 @@
282
282
  .tool-args {
283
283
  margin: 0;
284
284
  padding: 8px 12px;
285
- font-size: 11px;
285
+ font-family: var(--font-mono);
286
+ font-size: 12px;
287
+ font-weight: 600;
286
288
  line-height: 1.45;
289
+ color: var(--text-dim);
287
290
  /* 与标题同一个底色:直接用卡片底,展开后不再出现一块深米色 */
288
291
  background: transparent;
289
292
  /* 上方的分隔线由 .tool-card-body 提供,避免两条边线叠成 2px */
@@ -310,6 +313,7 @@
310
313
  gap: 6px;
311
314
  padding: 6px 12px 7px;
312
315
  border-top: 1px solid var(--border-soft);
316
+ font-family: var(--font-mono);
313
317
  font-size: 12px;
314
318
  font-weight: 600;
315
319
  color: var(--success);
@@ -332,6 +336,7 @@
332
336
  display: flex;
333
337
  align-items: flex-start;
334
338
  gap: 7px;
339
+ font-family: var(--font-mono);
335
340
  font-size: 12px;
336
341
  font-weight: 600;
337
342
  color: var(--success);
@@ -1270,6 +1275,14 @@
1270
1275
  padding: 10px 12px;
1271
1276
  }
1272
1277
 
1278
+ .tool-card-head,
1279
+ .tool-card-name,
1280
+ .tool-args,
1281
+ .tool-card-result-line,
1282
+ .tool-result {
1283
+ font-weight: 700;
1284
+ }
1285
+
1273
1286
  .composer {
1274
1287
  --attachment-preview-gap: 10px;
1275
1288
  padding: var(--attachment-preview-gap) 12px max(10px, env(safe-area-inset-bottom));
@@ -498,11 +498,29 @@
498
498
  font-weight: 700;
499
499
  font-size: 13.5px;
500
500
  }
501
+ .list-item.sortable-skill .l-title {
502
+ display: flex;
503
+ align-items: center;
504
+ gap: 8px;
505
+ min-width: 0;
506
+ }
507
+ .skill-card-name {
508
+ min-width: 0;
509
+ overflow: hidden;
510
+ text-overflow: ellipsis;
511
+ white-space: nowrap;
512
+ }
513
+ .list-item.sortable-skill .l-title .tag {
514
+ flex-shrink: 0;
515
+ }
501
516
  .list-item .l-sub {
502
517
  font-size: 11.5px;
503
518
  color: var(--text-faint);
504
519
  font-family: var(--font-mono);
505
520
  }
521
+ .list-item.sortable-skill .l-sub {
522
+ color: var(--text-dim);
523
+ }
506
524
 
507
525
  /* ---------- 技能表:拖拽排序 ---------- */
508
526
  .skill-sort-hint {
@@ -1376,6 +1394,7 @@
1376
1394
  }
1377
1395
  .raw-log-editor .cm-editor {
1378
1396
  background: var(--code-bg);
1397
+ font-family: var(--font-mono);
1379
1398
  }
1380
1399
  .raw-wrap-toggle {
1381
1400
  display: inline-flex;
@@ -1711,9 +1730,9 @@
1711
1730
  }
1712
1731
  .ws-fm-head {
1713
1732
  gap: 10px;
1714
- flex-wrap: wrap;
1715
1733
  }
1716
1734
  .ws-fm-head-actions {
1735
+ flex-shrink: 0;
1717
1736
  display: inline-flex;
1718
1737
  align-items: center;
1719
1738
  gap: 8px;
@@ -1727,7 +1746,7 @@
1727
1746
  font-weight: 800;
1728
1747
  font-size: 13.5px;
1729
1748
  }
1730
- .ws-fm-title .ws-fm-crumb {
1749
+ .ws-fm-crumb {
1731
1750
  min-width: 0;
1732
1751
  overflow: hidden;
1733
1752
  text-overflow: ellipsis;
@@ -1755,7 +1774,7 @@
1755
1774
  display: flex;
1756
1775
  flex-direction: column;
1757
1776
  gap: 10px;
1758
- padding: 14px 18px;
1777
+ padding: 14px;
1759
1778
  }
1760
1779
  .ws-fm-note {
1761
1780
  display: flex;