mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-15 12:53:31 +08:00
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:
parent
43f08dfe52
commit
bfafd26fb6
@ -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('')
|
||||
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('')
|
||||
expect(out.length).toBe(1)
|
||||
})
|
||||
|
||||
it('skips relative paths (cannot be resolved safely)', () => {
|
||||
const w = new ImageBlockWatcher()
|
||||
const out = w.feed(' and ')
|
||||
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('  ')
|
||||
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('')
|
||||
// Not in `;base64,` form → classify returns null → skipped
|
||||
expect(out.length).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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,
|
||||
},
|
||||
|
||||
@ -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' : ''}
|
||||
`}
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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: workDir,CLI 子进程里 preload.ts 还是会 chdir('/'),结果把
|
||||
// STATE.cwd / "Primary working directory" 打回根目录,IM 会话里 AI 感知的
|
||||
// 工作目录就变成 `/`。把 CALLER_DIR / PWD 显式覆盖成 workDir,preload.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',
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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 =
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user