Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
123 changes: 120 additions & 3 deletions web/src/app/store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -297,10 +297,24 @@ const sessionSlice = createSlice({
initialState: initialSession,
reducers: {
setSessions(s, a: { payload: SessionItem[] }) {
s.sessions = a.payload
// Same lazy-index race as setTasks: keep the open session if the server
// hasn't written it to the index yet.
const next = a.payload
const seen = new Set(next.map((x) => x.uuid))
const localOnly = s.sessions.filter(
(x) => !seen.has(x.uuid) && x.uuid === s.currentSessionId,
)
s.sessions = localOnly.length ? [...localOnly, ...next] : next
},
setTasks(s, a: { payload: TaskItem[] }) {
s.tasks = a.payload
// Preserve a just-created local task that isn't in the server index yet
// (session files are created lazily on the first user message).
const next = a.payload
const seen = new Set(next.map((t) => t.uuid))
const localOnly = s.tasks.filter(
(t) => !seen.has(t.uuid) && t.uuid === s.currentSessionId,
)
s.tasks = localOnly.length ? [...localOnly, ...next] : next
},
setCurrentSession(s, a: { payload: string }) {
s.currentSessionId = a.payload
Expand All @@ -315,6 +329,31 @@ const sessionSlice = createSlice({
const t = s.tasks.find((x) => x.uuid === a.payload.taskId)
if (t) t.running = a.payload.running
},
/** Insert or merge a task so the sidebar shows it immediately. */
upsertTask(s, a: { payload: TaskItem }) {
const i = s.tasks.findIndex((t) => t.uuid === a.payload.uuid)
if (i >= 0) {
s.tasks[i] = { ...s.tasks[i], ...a.payload }
} else {
s.tasks.unshift(a.payload)
}
},
/** Patch fields on an existing task (no-op if missing). */
patchTask(s, a: { payload: { uuid: string } & Partial<TaskItem> }) {
const t = s.tasks.find((x) => x.uuid === a.payload.uuid)
if (!t) return
const { uuid: _uuid, ...rest } = a.payload
Object.assign(t, rest)
},
/** Insert or merge a session for the active-project fallback list. */
upsertSession(s, a: { payload: SessionItem }) {
const i = s.sessions.findIndex((x) => x.uuid === a.payload.uuid)
if (i >= 0) {
s.sessions[i] = { ...s.sessions[i], ...a.payload }
} else {
s.sessions.unshift(a.payload)
}
},
},
})

Expand Down Expand Up @@ -519,11 +558,89 @@ export const sendMessage = createAsyncThunk(
}
dispatch(chatActions.addMessage({ role: 'user', content: payload.text, images: payload.images }))
dispatch(chatActions.setRunning(true))
// First user turn materializes the session on disk — surface it in the
// sidebar immediately (title + running) before the chat HTTP round-trip.
if (sessionId) {
revealSessionInSidebar(dispatch as AppDispatch, () => getState() as RootState, {
uuid: sessionId,
title: sessionTitleFromMessage(payload.text),
running: true,
})
}
const resp = await api.chat(payload.text, payload.mode, sessionId, payload.images)
if (!state.session.currentSessionId) dispatch(sessionActions.setCurrentSession(resp.session_id))
const sid = resp.session_id || sessionId
if (sid) {
if (!state.session.currentSessionId) dispatch(sessionActions.setCurrentSession(sid))
revealSessionInSidebar(dispatch as AppDispatch, () => getState() as RootState, {
uuid: sid,
title: sessionTitleFromMessage(payload.text),
running: true,
})
// Reconcile with the server index (now written by RecordUser).
void dispatch(loadSessions())
void dispatch(loadTasks())
}
},
)

/** Match backend generateTitle so the sidebar title doesn't flicker after refresh. */
function sessionTitleFromMessage(content: string): string {
const first = content.split(/\r?\n/, 1)[0]?.trim() ?? ''
if (!first) return 'New session'
const chars = Array.from(first)
return chars.length > 80 ? chars.slice(0, 80).join('') + '…' : first
}

/**
* Ensure a session/task appears in the left sidebar immediately.
* Backend only indexes a session after the first recorded message; empty
* "new chat" UUIDs are otherwise invisible until the next full reload.
*
* Takes dispatch/getState directly (not an RTK thunk) so it can be called
* from createAsyncThunk without AppDispatch vs ThunkDispatch type friction.
*/
export function revealSessionInSidebar(
dispatch: AppDispatch,
getState: () => RootState,
opts: {
uuid: string
title?: string
running?: boolean
project?: string
provider?: string
model?: string
},
) {
if (!opts.uuid) return
const state = getState()
const now = new Date().toISOString()
const project = opts.project || state.session.projectPath || ''
const existing = state.session.tasks.find((t) => t.uuid === opts.uuid)
// First non-empty title wins (matches backend generateTitle on first user msg).
const title = existing?.title || opts.title || ''
dispatch(sessionActions.upsertTask({
uuid: opts.uuid,
project: existing?.project || project,
created_at: existing?.created_at || now,
updated_at: now,
provider: opts.provider || existing?.provider || state.model.providerName || '',
model: opts.model || existing?.model || state.model.modelName || '',
title,
pinned: existing?.pinned ?? false,
archived: existing?.archived ?? false,
unread: existing?.unread ?? false,
status: existing?.status,
running: opts.running ?? existing?.running ?? false,
}))
dispatch(sessionActions.upsertSession({
uuid: opts.uuid,
created_at: existing?.created_at || now,
provider: opts.provider || existing?.provider || state.model.providerName || '',
model: opts.model || existing?.model || state.model.modelName || '',
title: title || undefined,
}))
}

export const stopAgent = createAsyncThunk('chat/stop', async (_, { getState }) => {
const state = getState() as RootState
await api.stop(state.session.currentSessionId || undefined)
Expand Down
5 changes: 5 additions & 0 deletions web/src/app/wsBridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ import {
sessionActions,
modelActions,
sendMessage,
loadTasks,
loadSessions,
} from './store'
import { api } from '../lib/api'
import type { Approval, Goal } from 'jcode-ui-core'
Expand Down Expand Up @@ -52,6 +54,9 @@ export function createWSHandlers(
onTokenUpdate: (d) => dispatch(chatActions.setTokenSnapshot(d)),
onAgentDone: (d) => {
dispatch(chatActions.agentDone(d ? { error: d.error } : undefined))
// Refresh sidebar metadata (title / updated_at / running) after a turn.
void dispatch(loadTasks() as never)
void dispatch(loadSessions() as never)
// Drain one queued type-ahead message (terminal-style), if any.
const queued = getState().chat.queued
if (queued.length > 0) {
Expand Down
2 changes: 1 addition & 1 deletion web/src/components/CommandPalette.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -43,8 +43,8 @@ export function CommandPalette() {
dispatch(uiActions.setView('chat'))
dispatch(chatActions.clearChat())
const resp = await api.newSession()
// Stay off the sidebar until the first user message (empty UUID rows look broken).
dispatch(sessionActions.setCurrentSession(resp.session_id))
await dispatch(loadWorkspaceState())
}

async function openTask(task: TaskItem) {
Expand Down
68 changes: 57 additions & 11 deletions web/src/components/Sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ import {
} from '@heroicons/react/24/outline'
import { useTranslation } from 'react-i18next'
import { useAppDispatch, useAppSelector } from '../app/hooks'
import { uiActions, sessionActions, chatActions, loadSession, loadTasks, loadWorkspaceState } from '../app/store'
import { uiActions, sessionActions, chatActions, loadSession, loadWorkspaceState } from '../app/store'
import { api } from '../lib/api'
import type { TaskItem } from '../lib/types'
import { ThemeToggle } from './ThemeToggle'
Expand Down Expand Up @@ -102,6 +102,12 @@ export function Sidebar() {
const [ctx, setCtx] = useState<CtxMenu | null>(null)
const ctxRef = useRef<HTMLDivElement | null>(null)

// Track rows that just appeared so we can play a one-shot enter animation
// (avoids a hard pop-in when a new session is revealed optimistically).
const knownUuids = useRef<Set<string>>(new Set())
const [entering, setEntering] = useState<Set<string>>(() => new Set())
const seededList = useRef(false)

// A coarse clock so time-based filtering/grouping re-evaluates as the wall
// clock advances (Date.now() isn't a reactive dep on its own).
const [now, setNow] = useState(() => Date.now())
Expand Down Expand Up @@ -152,6 +158,37 @@ export function Sidebar() {
return out
}, [tasks, sessions, activePath])

// After the first non-empty paint, animate only newly-added rows (not the
// initial hydrate of the whole list).
useEffect(() => {
const ids = rows.map((r) => r.uuid)
if (!seededList.current) {
if (ids.length === 0) return
knownUuids.current = new Set(ids)
seededList.current = true
return
}
const added: string[] = []
for (const id of ids) {
if (!knownUuids.current.has(id)) added.push(id)
}
knownUuids.current = new Set(ids)
if (added.length === 0) return
setEntering((prev) => {
const next = new Set(prev)
for (const id of added) next.add(id)
return next
})
const t = window.setTimeout(() => {
setEntering((prev) => {
const next = new Set(prev)
for (const id of added) next.delete(id)
return next
})
}, 220)
return () => window.clearTimeout(t)
}, [rows])

const projects = useMemo(() => {
const map = new Map<string, string>()
if (activePath) map.set(activePath, projectName(activePath))
Expand All @@ -174,6 +211,10 @@ export function Sidebar() {

const filtered = useMemo(() => {
return rows.filter((r) => {
// Untitled rows are empty sessions that never recorded a message (or
// optimistic placeholders). Keep them out of the list — welcome stays
// clean until the first user turn materializes a real title.
if (!r.title?.trim() && !r.running) return false
// The open conversation always stays visible regardless of filters —
// otherwise archiving or a narrowing window would strand it.
if (r.uuid === currentSessionId) return true
Expand All @@ -193,17 +234,19 @@ export function Sidebar() {

// ── Sort: running first, then pinned, then the chosen key ──

const untitledLabel = t('sidebar.untitled')

const sorted = useMemo(() => {
const arr = [...filtered]
arr.sort((a, b) => {
if (a.running !== b.running) return a.running ? -1 : 1
if (a.pinned !== b.pinned) return a.pinned ? -1 : 1
if (filters.sort === 'name') return rowTitle(a).localeCompare(rowTitle(b))
if (filters.sort === 'name') return rowTitle(a, untitledLabel).localeCompare(rowTitle(b, untitledLabel))
if (filters.sort === 'created') return (b.created_at || '').localeCompare(a.created_at || '')
return (b.updated_at || '').localeCompare(a.updated_at || '')
})
return arr
}, [filtered, filters.sort])
}, [filtered, filters.sort, untitledLabel])

// ── Group by project or recency ──

Expand Down Expand Up @@ -281,10 +324,9 @@ export function Sidebar() {
dispatch(uiActions.setView('chat'))
try {
const resp = await api.newSession()
// Keep the welcome screen empty-session out of the sidebar until the
// first user message (backend only indexes then; a UUID-only row looks broken).
dispatch(sessionActions.setCurrentSession(resp.session_id))
const fresh = await api.sessions()
dispatch(sessionActions.setSessions(fresh))
dispatch(loadTasks())
} catch {
// surfaced via health/gate
}
Expand Down Expand Up @@ -566,7 +608,7 @@ export function Sidebar() {
}
}}
onContextMenu={(e) => openContext(e, row)}
className={`sb-task-row group ${active ? 'active' : ''} ${row.archived ? 'archived' : ''} ${row.running ? 'running' : ''}`}
className={`sb-task-row group ${active ? 'active' : ''} ${row.archived ? 'archived' : ''} ${row.running ? 'running' : ''} ${entering.has(row.uuid) ? 'sb-task-enter' : ''}`}
>
{row.running ? (
<span className="sb-ring h-[11px] w-[11px] shrink-0" aria-hidden="true" />
Expand All @@ -577,7 +619,7 @@ export function Sidebar() {
/>
)}
{row.pinned && <BookmarkIcon className="sb-task-pin h-2.5 w-2.5 shrink-0" />}
<span className="sb-task-title">{rowTitle(row)}</span>
<span className="sb-task-title">{rowTitle(row, untitledLabel)}</span>
{!isProject && <span className="sb-task-project">{projectName(row.project)}</span>}
<span className={`sb-task-time ${row.running ? 'running' : ''}`}>
{row.running ? t('sidebar.running') : relativeTime(row.updated_at || row.created_at, now, t)}
Expand Down Expand Up @@ -637,10 +679,11 @@ export function Sidebar() {
)
: null}

{/* Animations: running ring breathe + pop-in for menus. Scoped via sb-* names. */}
{/* Animations: running ring breathe + pop-in for menus / new rows. Scoped via sb-* names. */}
<style>{`
@keyframes sb-ring-breathe { 0%,100% { opacity:0.35; transform:scale(0.78); } 50% { opacity:1; transform:scale(1); } }
@keyframes sb-pop-in { from { opacity:0; transform:translateY(-4px); } to { opacity:1; transform:none; } }
@keyframes sb-task-enter { from { opacity:0; transform:translateY(-6px); } to { opacity:1; transform:none; } }
.sb-header { padding: 48px 12px 6px; }
html.is-tauri-macos .sb-header { padding-top: 20px; }
.sb-nav-list { display:flex; flex-direction:column; gap:6px; }
Expand Down Expand Up @@ -714,6 +757,7 @@ export function Sidebar() {
/* Match Vue: no custom line-height — inherit body (tight, single-line rows). */
line-height: normal;
}
.sb-task-row.sb-task-enter { animation: sb-task-enter 0.2s ease; }
.sb-task-row:hover { background:var(--color-muted); }
.sb-task-row.active { background:var(--neutral-wash-soft); border-left-color:var(--color-accent-neutral); }
.sb-task-row.archived { opacity:0.55; }
Expand Down Expand Up @@ -775,6 +819,7 @@ export function Sidebar() {
@media (prefers-reduced-motion: reduce) {
.sb-ring { animation: none; opacity: 1; transform: none; }
.sb-pop { animation: none; }
.sb-task-row.sb-task-enter { animation: none; }
}
`}</style>
</aside>
Expand Down Expand Up @@ -811,8 +856,9 @@ function CtxItem({

// ─── Helpers ───

function rowTitle(r: SessionRow): string {
return r.title || r.uuid.slice(0, 8) + '…'
function rowTitle(r: SessionRow, untitled: string): string {
// Never fall back to a raw UUID fragment — it reads as garbled noise.
return r.title?.trim() || untitled
}

function taskToRow(t: TaskItem): SessionRow {
Expand Down
1 change: 1 addition & 0 deletions web/src/i18n/locales/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -676,6 +676,7 @@ export default {
noProjects: 'No projects yet',
noTasks: 'No tasks',
running: 'Running',
untitled: 'New chat',
newTaskHere: 'New task here',
filter: {
title: 'Filters & sorting',
Expand Down
1 change: 1 addition & 0 deletions web/src/i18n/locales/ja.ts
Original file line number Diff line number Diff line change
Expand Up @@ -627,6 +627,7 @@ export default {
noProjects: 'プロジェクトがまだありません',
noTasks: 'タスクなし',
running: '実行中',
untitled: '新しいチャット',
newTaskHere: 'ここで新規タスク',
filter: {
title: 'フィルターと並べ替え',
Expand Down
1 change: 1 addition & 0 deletions web/src/i18n/locales/ko.ts
Original file line number Diff line number Diff line change
Expand Up @@ -627,6 +627,7 @@ export default {
noProjects: '프로젝트가 아직 없습니다',
noTasks: '작업 없음',
running: '실행 중',
untitled: '새 채팅',
newTaskHere: '여기에 새 작업',
filter: {
title: '필터 및 정렬',
Expand Down
1 change: 1 addition & 0 deletions web/src/i18n/locales/zh-Hans.ts
Original file line number Diff line number Diff line change
Expand Up @@ -660,6 +660,7 @@ export default {
noProjects: '暂无项目',
noTasks: '暂无任务',
running: '运行中',
untitled: '新会话',
newTaskHere: '在此新建任务',
filter: {
title: '筛选与排序',
Expand Down
1 change: 1 addition & 0 deletions web/src/i18n/locales/zh-Hant.ts
Original file line number Diff line number Diff line change
Expand Up @@ -628,6 +628,7 @@ export default {
noProjects: '暫無專案',
noTasks: '暫無工作',
running: '執行中',
untitled: '新對話',
newTaskHere: '在此新建工作',
filter: {
title: '篩選與排序',
Expand Down
Loading
Loading