fix: batch resolve 5 issues from Feishu bug tracker

- fix(desktop): Settings Agents/Skills page layout overflow at narrow widths
  Shift header grid breakpoint md→xl, summary cards sm→3col, add min-w-0/truncate

- fix(desktop): TabBar tab style — remove border-r separators, use bg-only active state
  Drop ::after indicator and inter-tab borders; active tab distinguished by surface bg

- feat(desktop): IM Adapters page — horizontal tabs with Feishu first, Telegram second
  Replace vertical stack with tablist; add ImTabButton with aria-selected + focus-visible

- fix(server): bundled IM session cwd resolves to / instead of selected project
  Root cause: preload.ts chdir(CALLER_DIR) inherits '/' from Tauri-spawned sidecar.
  Fix: pin CALLER_DIR and PWD to session workDir in conversationService spawn env.
  Add diagnostic logs at sessionService, ws/handler, and conversationService.

- fix(feishu): StreamingCard shows duplicate "thinking" indicators
  Merge static loading element into streaming_content; renderedText() controls state.

- test(adapters): add 5 ImageBlockWatcher edge-case unit tests (reset, relative path,
  multi-image chunk, malformed data URI)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
程序员阿江(Relakkes) 2026-04-12 13:36:14 +08:00
parent 43f08dfe52
commit bfafd26fb6
10 changed files with 225 additions and 137 deletions

View File

@ -76,4 +76,40 @@ describe('ImageBlockWatcher', () => {
const all = w.drain()
expect(all.length).toBe(2)
})
it('reset() clears buffer, seen set, and accumulated list', () => {
const w = new ImageBlockWatcher()
w.feed('![a](/tmp/a.png)')
w.reset()
// After reset, drain() is empty
expect(w.drain().length).toBe(0)
// And re-feeding the same image yields a fresh emit (dedup state cleared)
const out = w.feed('![a](/tmp/a.png)')
expect(out.length).toBe(1)
})
it('skips relative paths (cannot be resolved safely)', () => {
const w = new ImageBlockWatcher()
const out = w.feed('![rel](relative/path.png) and ![ok](/tmp/ok.png)')
expect(out.length).toBe(1)
const source = out[0]!.source
expect(source.kind).toBe('path')
if (source.kind === 'path') expect(source.path).toBe('/tmp/ok.png')
})
it('extracts multiple images from a single feed chunk in order', () => {
const w = new ImageBlockWatcher()
const out = w.feed('![a](/tmp/a.png) ![b](https://x/b.png) ![c](data:image/png;base64,QQ==)')
expect(out.length).toBe(3)
expect(out[0]!.source.kind).toBe('path')
expect(out[1]!.source.kind).toBe('url')
expect(out[2]!.source.kind).toBe('base64')
})
it('rejects malformed data URI (not base64)', () => {
const w = new ImageBlockWatcher()
const out = w.feed('![bad](data:image/png,ABC)')
// Not in `;base64,` form → classify returns null → skipped
expect(out.length).toBe(0)
})
})

View File

@ -102,23 +102,13 @@ describe('buildInitialStreamingCard', () => {
const card = buildInitialStreamingCard() as any
expect(card.schema).toBe('2.0')
expect(card.config.streaming_mode).toBe(true)
// 第二个元素是空的 streaming_content 目标loading 在首位以避免顶部空 padding
const streaming = card.body.elements[1]
expect(streaming.tag).toBe('markdown')
expect(streaming.content).toBe('')
expect(streaming.element_id).toBe(STREAMING_ELEMENT_ID)
})
it('loading 提示元素在首位(避免空 streaming_content 挤出顶部 padding', () => {
const card = buildInitialStreamingCard() as any
// 唯一元素streaming_content初始内容为 loading 提示
const elements = card.body.elements as any[]
expect(elements.length).toBe(2)
const loading = elements[0]
expect(loading.tag).toBe('markdown')
expect(loading.content).toContain('正在思考中')
expect(loading.text_size).toBe('notation')
// loading 元素不能有 element_id那是给 streaming_content 独占的)
expect(loading.element_id).toBeUndefined()
expect(elements.length).toBe(1)
const streaming = elements[0]
expect(streaming.tag).toBe('markdown')
expect(streaming.content).toContain('正在思考中')
expect(streaming.element_id).toBe(STREAMING_ELEMENT_ID)
})
})
@ -176,8 +166,8 @@ describe('StreamingCard: ensureCreated (CardKit 主路径)', () => {
const cardJson = JSON.parse(calls[0]!.args.data.data)
expect(cardJson.schema).toBe('2.0')
expect(cardJson.config.streaming_mode).toBe(true)
// loading 元素在首位streaming_content 占位在第二
expect(cardJson.body.elements[1].element_id).toBe(STREAMING_ELEMENT_ID)
// 唯一元素即 streaming_content
expect(cardJson.body.elements[0].element_id).toBe(STREAMING_ELEMENT_ID)
// IM message 引用 card_id
const content = JSON.parse(calls[1]!.args.data.content)

View File

@ -31,17 +31,11 @@ import { optimizeMarkdownForFeishu, sanitizeTextForCard } from './markdown-style
/** Schema 2.0 + streaming_mode + element_id
*
* markdown
* - loading : "☁️ 正在思考中..." ****
* streaming_content loading
* padding
* - `streaming_content`: cardElement.content()
* loading
* markdown `streaming_content` loading
* renderedText() / reasoning /
* loading streaming "两个思考中"
*
* finalize update loading
*
* openclaw-lark `custom_icon` + img_key loading
* img_key emoji + notation "仍在处理" */
* finalize update */
export function buildInitialStreamingCard(): Record<string, unknown> {
return {
schema: '2.0',
@ -54,11 +48,6 @@ export function buildInitialStreamingCard(): Record<string, unknown> {
{
tag: 'markdown',
content: '☁️ *正在思考中...*',
text_size: 'notation',
},
{
tag: 'markdown',
content: '',
text_align: 'left',
element_id: STREAMING_ELEMENT_ID,
},

View File

@ -148,7 +148,7 @@ export function TabBar() {
if (tabs.length === 0) return null
return (
<div className="flex items-center bg-[var(--color-surface-container)] min-h-[37px] select-none border-b border-[var(--color-border)]" data-tauri-drag-region>
<div className="flex items-stretch bg-[var(--color-surface-container)] min-h-[37px] select-none border-b border-[var(--color-border)]" data-tauri-drag-region>
{canScrollLeft && (
<button onClick={() => scroll('left')} className="flex-shrink-0 w-7 h-[37px] flex items-center justify-center text-[var(--color-text-tertiary)] hover:text-[var(--color-text-primary)] hover:bg-[var(--color-surface-hover)]">
@ -156,7 +156,7 @@ export function TabBar() {
</button>
)}
<div ref={scrollRef} className="flex-1 flex items-center overflow-x-hidden" data-tauri-drag-region onDragOver={(e) => e.preventDefault()}>
<div ref={scrollRef} className="flex-1 flex items-stretch overflow-x-hidden" data-tauri-drag-region onDragOver={(e) => e.preventDefault()}>
{tabs.map((tab, index) => (
<TabItem
key={tab.sessionId}
@ -275,10 +275,10 @@ function TabItem({ tab, isActive, isDragOver, onClick, onClose, onContextMenu, o
onDrop={onDrop}
onDragEnd={onDragEnd}
className={`
flex-shrink-0 flex items-center gap-1.5 px-3 cursor-pointer group transition-colors relative border-r border-r-[var(--color-border)]/30
flex-shrink-0 flex items-center gap-1.5 px-3 min-h-[37px] cursor-pointer group transition-colors relative
${isActive
? 'h-[37px] bg-[var(--color-surface)] border-t-2 border-t-[var(--color-brand)]'
: 'h-[37px] bg-transparent hover:bg-[var(--color-surface-hover)]'
? 'bg-[var(--color-surface)]'
: 'bg-transparent hover:bg-[var(--color-surface-hover)]'
}
${isDragOver ? 'before:absolute before:left-0 before:top-[6px] before:bottom-[6px] before:w-[2px] before:bg-[var(--color-brand)] before:rounded-full' : ''}
`}

View File

@ -84,7 +84,7 @@ export function SkillList() {
return (
<div className="flex flex-col gap-6 min-w-0">
<section className="rounded-2xl border border-[var(--color-border)] bg-[var(--color-surface-container-low)] overflow-hidden">
<div className="grid gap-4 px-5 py-5 md:grid-cols-[minmax(0,1.6fr)_minmax(280px,1fr)] md:items-end">
<div className="grid gap-4 px-5 py-5 min-w-0 xl:grid-cols-[minmax(0,1.6fr)_minmax(320px,1fr)] xl:items-end">
<div className="min-w-0">
<div className="text-[11px] font-semibold uppercase tracking-[0.2em] text-[var(--color-text-tertiary)] mb-2">
{t('settings.skills.browserEyebrow')}
@ -102,7 +102,7 @@ export function SkillList() {
</p>
</div>
<div className="grid grid-cols-2 gap-3 md:grid-cols-3">
<div className="grid grid-cols-2 gap-3 min-w-0 sm:grid-cols-3">
<SummaryCard
label={t('settings.skills.summary.totalSkills')}
value={String(skills.length)}
@ -120,7 +120,7 @@ export function SkillList() {
label={t('settings.skills.summary.tokens')}
value={t('settings.skills.tokenEstimateShort', { count: String(totalTokens) })}
icon="notes"
className="col-span-2 md:col-span-1"
className="col-span-2 sm:col-span-1"
/>
</div>
</div>
@ -236,12 +236,12 @@ function SummaryCard({
className?: string
}) {
return (
<div className={`rounded-xl border border-[var(--color-border)] bg-[var(--color-surface)] px-3 py-3 ${className}`}>
<div className="flex items-center gap-2 text-[11px] uppercase tracking-[0.16em] text-[var(--color-text-tertiary)]">
<span className="material-symbols-outlined text-[14px]">{icon}</span>
<span>{label}</span>
<div className={`rounded-xl border border-[var(--color-border)] bg-[var(--color-surface)] px-3 py-3 min-w-0 ${className}`}>
<div className="flex items-center gap-1.5 text-[11px] uppercase tracking-[0.12em] text-[var(--color-text-tertiary)] min-w-0">
<span className="material-symbols-outlined text-[14px] flex-shrink-0">{icon}</span>
<span className="truncate">{label}</span>
</div>
<div className="mt-2 text-lg font-semibold text-[var(--color-text-primary)]">
<div className="mt-2 text-lg font-semibold text-[var(--color-text-primary)] truncate">
{value}
</div>
</div>

View File

@ -5,10 +5,15 @@ import { Input } from '../components/shared/Input'
import { Button } from '../components/shared/Button'
import { DirectoryPicker } from '../components/shared/DirectoryPicker'
type ImTab = 'feishu' | 'telegram'
export function AdapterSettings() {
const t = useTranslation()
const { config, isLoading, fetchConfig, updateConfig, generatePairingCode, removePairedUser } = useAdapterStore()
// Active IM tab —— Feishu 默认展示,在前
const [activeIm, setActiveIm] = useState<ImTab>('feishu')
// Server —— serverUrl 不再暴露在 UI 里(见下方 Server URL 注释),
// 桌面端用 Tauri env var 注入动态端口。
const [defaultProjectDir, setDefaultProjectDir] = useState('')
@ -227,90 +232,98 @@ export function AdapterSettings() {
</p>
</div>
{/* Telegram */}
{/* IM Adapter Tabs —— Feishu 默认在前,Telegram 在后 */}
<section className="rounded-xl border border-[var(--color-border)] overflow-hidden">
<div className="flex items-center gap-2 px-4 py-3 bg-[var(--color-surface-hover)] border-b border-[var(--color-border)]">
<span className="text-sm font-semibold text-[var(--color-text-primary)]">{t('settings.adapters.telegram')}</span>
</div>
<div className="p-4 space-y-4">
<Input
label={t('settings.adapters.botToken')}
type="password"
value={tgBotToken}
onChange={(e) => setTgBotToken(e.target.value)}
placeholder={t('settings.adapters.botTokenPlaceholder')}
<div role="tablist" aria-label="IM adapter" className="flex items-stretch border-b border-[var(--color-border)] bg-[var(--color-surface-hover)]">
<ImTabButton
label={t('settings.adapters.feishu')}
active={activeIm === 'feishu'}
onClick={() => setActiveIm('feishu')}
/>
<ImTabButton
label={t('settings.adapters.telegram')}
active={activeIm === 'telegram'}
onClick={() => setActiveIm('telegram')}
/>
<div className="flex flex-col gap-1">
<Input
label={t('settings.adapters.allowedUsers')}
value={tgAllowedUsers}
onChange={(e) => setTgAllowedUsers(e.target.value)}
placeholder={t('settings.adapters.tgAllowedUsersPlaceholder')}
/>
<p className="text-xs text-[var(--color-text-tertiary)]">{t('settings.adapters.allowedUsersHint')}</p>
</div>
</div>
</section>
{/* Feishu */}
<section className="rounded-xl border border-[var(--color-border)] overflow-hidden">
<div className="flex items-center gap-2 px-4 py-3 bg-[var(--color-surface-hover)] border-b border-[var(--color-border)]">
<span className="text-sm font-semibold text-[var(--color-text-primary)]">{t('settings.adapters.feishu')}</span>
</div>
<div className="p-4 space-y-4">
<div className="grid grid-cols-2 gap-4">
<Input
label={t('settings.adapters.appId')}
value={fsAppId}
onChange={(e) => setFsAppId(e.target.value)}
placeholder={t('settings.adapters.appIdPlaceholder')}
/>
<Input
label={t('settings.adapters.appSecret')}
type="password"
value={fsAppSecret}
onChange={(e) => setFsAppSecret(e.target.value)}
placeholder={t('settings.adapters.appSecretPlaceholder')}
/>
</div>
<div className="grid grid-cols-2 gap-4">
<Input
label={t('settings.adapters.encryptKey')}
type="password"
value={fsEncryptKey}
onChange={(e) => setFsEncryptKey(e.target.value)}
placeholder={t('settings.adapters.encryptKeyPlaceholder')}
/>
<Input
label={t('settings.adapters.verificationToken')}
type="password"
value={fsVerificationToken}
onChange={(e) => setFsVerificationToken(e.target.value)}
placeholder={t('settings.adapters.verificationTokenPlaceholder')}
/>
</div>
<div className="flex flex-col gap-1">
<Input
label={t('settings.adapters.allowedUsers')}
value={fsAllowedUsers}
onChange={(e) => setFsAllowedUsers(e.target.value)}
placeholder={t('settings.adapters.fsAllowedUsersPlaceholder')}
/>
<p className="text-xs text-[var(--color-text-tertiary)]">{t('settings.adapters.allowedUsersHint')}</p>
</div>
<label className="flex items-center gap-3 cursor-pointer">
<input
type="checkbox"
checked={fsStreamingCard}
onChange={(e) => setFsStreamingCard(e.target.checked)}
className="w-4 h-4 rounded border-[var(--color-border)] accent-[var(--color-brand)]"
/>
<div>
<span className="text-sm text-[var(--color-text-primary)]">{t('settings.adapters.streamingCard')}</span>
<p className="text-xs text-[var(--color-text-tertiary)]">{t('settings.adapters.streamingCardDesc')}</p>
{activeIm === 'feishu' && (
<div className="p-4 space-y-4">
<div className="grid grid-cols-2 gap-4">
<Input
label={t('settings.adapters.appId')}
value={fsAppId}
onChange={(e) => setFsAppId(e.target.value)}
placeholder={t('settings.adapters.appIdPlaceholder')}
/>
<Input
label={t('settings.adapters.appSecret')}
type="password"
value={fsAppSecret}
onChange={(e) => setFsAppSecret(e.target.value)}
placeholder={t('settings.adapters.appSecretPlaceholder')}
/>
</div>
</label>
</div>
<div className="grid grid-cols-2 gap-4">
<Input
label={t('settings.adapters.encryptKey')}
type="password"
value={fsEncryptKey}
onChange={(e) => setFsEncryptKey(e.target.value)}
placeholder={t('settings.adapters.encryptKeyPlaceholder')}
/>
<Input
label={t('settings.adapters.verificationToken')}
type="password"
value={fsVerificationToken}
onChange={(e) => setFsVerificationToken(e.target.value)}
placeholder={t('settings.adapters.verificationTokenPlaceholder')}
/>
</div>
<div className="flex flex-col gap-1">
<Input
label={t('settings.adapters.allowedUsers')}
value={fsAllowedUsers}
onChange={(e) => setFsAllowedUsers(e.target.value)}
placeholder={t('settings.adapters.fsAllowedUsersPlaceholder')}
/>
<p className="text-xs text-[var(--color-text-tertiary)]">{t('settings.adapters.allowedUsersHint')}</p>
</div>
<label className="flex items-center gap-3 cursor-pointer">
<input
type="checkbox"
checked={fsStreamingCard}
onChange={(e) => setFsStreamingCard(e.target.checked)}
className="w-4 h-4 rounded border-[var(--color-border)] accent-[var(--color-brand)]"
/>
<div>
<span className="text-sm text-[var(--color-text-primary)]">{t('settings.adapters.streamingCard')}</span>
<p className="text-xs text-[var(--color-text-tertiary)]">{t('settings.adapters.streamingCardDesc')}</p>
</div>
</label>
</div>
)}
{activeIm === 'telegram' && (
<div className="p-4 space-y-4">
<Input
label={t('settings.adapters.botToken')}
type="password"
value={tgBotToken}
onChange={(e) => setTgBotToken(e.target.value)}
placeholder={t('settings.adapters.botTokenPlaceholder')}
/>
<div className="flex flex-col gap-1">
<Input
label={t('settings.adapters.allowedUsers')}
value={tgAllowedUsers}
onChange={(e) => setTgAllowedUsers(e.target.value)}
placeholder={t('settings.adapters.tgAllowedUsersPlaceholder')}
/>
<p className="text-xs text-[var(--color-text-tertiary)]">{t('settings.adapters.allowedUsersHint')}</p>
</div>
</div>
)}
</section>
{/* Save */}
@ -334,3 +347,29 @@ export function AdapterSettings() {
</div>
)
}
function ImTabButton({
label,
active,
onClick,
}: {
label: string
active: boolean
onClick: () => void
}) {
return (
<button
type="button"
role="tab"
aria-selected={active}
onClick={onClick}
className={`relative px-4 py-2.5 text-sm transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-brand)] focus-visible:ring-inset ${
active
? 'text-[var(--color-text-primary)] font-semibold after:absolute after:left-3 after:right-3 after:bottom-0 after:h-[2px] after:bg-[var(--color-brand)]'
: 'text-[var(--color-text-secondary)] hover:text-[var(--color-text-primary)]'
}`}
>
{label}
</button>
)
}

View File

@ -765,7 +765,7 @@ function AgentsSettings() {
) : (
<div className="flex flex-col gap-6 min-w-0">
<section className="rounded-2xl border border-[var(--color-border)] bg-[var(--color-surface-container-low)] overflow-hidden">
<div className="grid gap-4 px-5 py-5 md:grid-cols-[minmax(0,1.6fr)_minmax(280px,1fr)] md:items-end">
<div className="grid gap-4 px-5 py-5 min-w-0 xl:grid-cols-[minmax(0,1.6fr)_minmax(320px,1fr)] xl:items-end">
<div className="min-w-0">
<div className="text-[11px] font-semibold uppercase tracking-[0.2em] text-[var(--color-text-tertiary)] mb-2">
{t('settings.agents.browserEyebrow')}
@ -783,7 +783,7 @@ function AgentsSettings() {
</p>
</div>
<div className="grid grid-cols-2 gap-3 md:grid-cols-3">
<div className="grid grid-cols-2 gap-3 min-w-0 sm:grid-cols-3">
<SummaryCard
label={t('settings.agents.summary.totalAgents')}
value={String(allAgents.length)}
@ -798,7 +798,7 @@ function AgentsSettings() {
label={t('settings.agents.summary.sources')}
value={String(sourceCount)}
icon="layers"
className="col-span-2 md:col-span-1"
className="col-span-2 sm:col-span-1"
/>
</div>
</div>
@ -1119,12 +1119,12 @@ function SummaryCard({
className?: string
}) {
return (
<div className={`rounded-xl border border-[var(--color-border)] bg-[var(--color-surface)] px-3 py-3 ${className}`}>
<div className="flex items-center gap-2 text-[11px] uppercase tracking-[0.16em] text-[var(--color-text-tertiary)]">
<span className="material-symbols-outlined text-[14px]">{icon}</span>
<span>{label}</span>
<div className={`rounded-xl border border-[var(--color-border)] bg-[var(--color-surface)] px-3 py-3 min-w-0 ${className}`}>
<div className="flex items-center gap-1.5 text-[11px] uppercase tracking-[0.12em] text-[var(--color-text-tertiary)] min-w-0">
<span className="material-symbols-outlined text-[14px] flex-shrink-0">{icon}</span>
<span className="truncate">{label}</span>
</div>
<div className="mt-2 text-lg font-semibold text-[var(--color-text-primary)]">
<div className="mt-2 text-lg font-semibold text-[var(--color-text-primary)] truncate">
{value}
</div>
</div>

View File

@ -105,14 +105,30 @@ export class ConversationService {
])
console.log(
`[ConversationService] Starting CLI for ${sessionId}, cwd: ${workDir}`,
`[ConversationService] Starting CLI for ${sessionId}, cwd: ${workDir} (process.cwd()=${process.cwd()}, CALLER_DIR will be pinned to workDir)`,
)
// IMPORTANT (Bug#5): 必须覆盖子进程继承的 CALLER_DIR / PWD。
// preload.ts 顶层读 process.env.CALLER_DIR 并调用 process.chdir(CALLER_DIR)。
// 在 bundled 桌面端里server sidecar 被 Tauri 从 cwd=/ 启动claude-sidecar.ts
// 在 server/cli 模式入口把 CALLER_DIR 默认设成 process.cwd()(即 '/'
// 随后这个 env 被完整继承到 Bun.spawn 的 CLI 子进程;即使这里显式传了
// cwd: workDirCLI 子进程里 preload.ts 还是会 chdir('/'),结果把
// STATE.cwd / "Primary working directory" 打回根目录IM 会话里 AI 感知的
// 工作目录就变成 `/`。把 CALLER_DIR / PWD 显式覆盖成 workDirpreload.ts
// chdir 后落到正确目录。
const childEnv = {
...process.env,
CLAUDE_CODE_ENABLE_TASKS: '1',
CALLER_DIR: workDir,
PWD: workDir,
}
let proc: ReturnType<typeof Bun.spawn>
try {
proc = Bun.spawn(args, {
cwd: workDir,
env: { ...process.env, CLAUDE_CODE_ENABLE_TASKS: '1' },
env: childEnv,
stdin: 'pipe',
stdout: 'ignore', // CLI communicates via SDK WebSocket, not stdout
stderr: 'pipe',

View File

@ -552,8 +552,16 @@ export class SessionService {
// Default to user home directory when no workDir specified
const resolvedWorkDir = workDir || os.homedir()
// Resolve to absolute path
// Resolve to absolute path. NOTE: path.resolve() uses process.cwd() to
// expand relative paths — in bundled sidecar mode the server's cwd is
// typically '/'. Callers (IM adapters) already send absolute realPath,
// but we log here so cwd regressions are caught early.
const absWorkDir = path.resolve(resolvedWorkDir)
console.log(
`[SessionService] createSession: requested workDir=${JSON.stringify(
workDir,
)}, resolved=${absWorkDir} (process.cwd()=${process.cwd()})`,
)
let stat
try {
stat = await fs.stat(absWorkDir)

View File

@ -165,8 +165,18 @@ async function handleUserMessage(
try {
const resolved = await sessionService.getSessionWorkDir(sessionId)
if (resolved) workDir = resolved
} catch {
console.log(
`[WS] handleUserMessage: sessionId=${sessionId}, resolved workDir=${JSON.stringify(
resolved,
)}, will spawn CLI with workDir=${workDir}`,
)
} catch (resolveErr) {
// fallback to cwd if session file not found
console.warn(
`[WS] handleUserMessage: failed to resolve workDir for ${sessionId}, using fallback=${workDir}: ${
resolveErr instanceof Error ? resolveErr.message : String(resolveErr)
}`,
)
}
const runtimeSettings = await getRuntimeSettings()
const sdkUrl =