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
4 changes: 2 additions & 2 deletions backend/src/routes/sessions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -93,15 +93,15 @@ export async function sessionRoutes(fastify: FastifyInstance) {
const modeRow = db
.prepare("SELECT value FROM settings WHERE key = 'session_mode'")
.get() as { value: string } | undefined
const mode = modeRow?.value === 'terminal' ? 'terminal' : 'chat'
const mode = modeRow?.value === 'chat' ? 'chat' : 'terminal'

const id = randomUUID()
const now = Date.now()
db.prepare(
'INSERT INTO sessions (id, workdir, name, mode, started_at, last_used) VALUES (?, ?, ?, ?, ?, ?)',
).run(id, workdir, name?.trim() || null, mode, now, now)

return reply.status(201).send({ sessionId: id })
return reply.status(201).send({ sessionId: id, mode })
})

fastify.post<{ Params: { id: string } }>('/api/sessions/:id/stop', async (req, reply) => {
Expand Down
30 changes: 29 additions & 1 deletion backend/src/ws/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -448,7 +448,15 @@ export async function sessionWsRoutes(fastify: FastifyInstance) {
const { id } = req.params

const row = db.prepare('SELECT * FROM sessions WHERE id = ?').get(id) as
| { workdir: string; ended_at: number | null; claude_session_id: string | null; total_tokens: number; working_time_ms: number }
| {
workdir: string; ended_at: number | null; claude_session_id: string | null
total_tokens: number; working_time_ms: number; model: string | null
cost_usd: number | null; api_duration_ms: number | null
lines_added: number | null; lines_removed: number | null
context_input_tokens: number | null; context_output_tokens: number | null
context_window_size: number | null; context_pct: number | null
effort_level: string | null; thinking_enabled: number | null
}
| undefined

if (!row) {
Expand All @@ -475,6 +483,26 @@ export async function sessionWsRoutes(fastify: FastifyInstance) {
gitBranch: getGitBranch(row.workdir),
}))

// Replay last statusline data so reconnecting clients see up-to-date header stats
// (cost, API duration, effort, etc.) without waiting for the next statusline push.
socket.send(JSON.stringify({
type: 'statusline',
statuslineData: {
model: row.model ?? null,
costUsd: row.cost_usd ?? null,
apiDurationMs: row.api_duration_ms ?? null,
linesAdded: row.lines_added ?? null,
linesRemoved: row.lines_removed ?? null,
contextInputTokens: row.context_input_tokens ?? null,
contextOutputTokens: row.context_output_tokens ?? null,
contextWindowSize: row.context_window_size ?? null,
contextPct: row.context_pct ?? null,
effortLevel: row.effort_level ?? null,
thinkingEnabled: row.thinking_enabled !== null ? row.thinking_enabled === 1 : null,
rateLimits: null,
} satisfies StatuslinePayload,
}))

// Clear ended_at so resumed sessions show as active; stamp last_used
db.prepare('UPDATE sessions SET last_used = ?, ended_at = NULL WHERE id = ?').run(Date.now(), id)

Expand Down
37 changes: 35 additions & 2 deletions backend/src/ws/terminal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -287,8 +287,21 @@ export async function terminalWsRoutes(fastify: FastifyInstance) {
(socket, req) => {
const { id } = req.params

const row = db.prepare('SELECT workdir, ended_at, claude_session_id, context_input_tokens, context_output_tokens FROM sessions WHERE id = ?').get(id) as
| { workdir: string; ended_at: number | null; claude_session_id: string | null; context_input_tokens: number | null; context_output_tokens: number | null }
const row = db.prepare(
`SELECT workdir, ended_at, claude_session_id,
model, cost_usd, api_duration_ms, lines_added, lines_removed,
context_input_tokens, context_output_tokens, context_window_size, context_pct,
effort_level, thinking_enabled
FROM sessions WHERE id = ?`,
).get(id) as
| {
workdir: string; ended_at: number | null; claude_session_id: string | null
model: string | null; cost_usd: number | null; api_duration_ms: number | null
lines_added: number | null; lines_removed: number | null
context_input_tokens: number | null; context_output_tokens: number | null
context_window_size: number | null; context_pct: number | null
effort_level: string | null; thinking_enabled: number | null
}
| undefined

if (!row) {
Expand Down Expand Up @@ -319,6 +332,26 @@ export async function terminalWsRoutes(fastify: FastifyInstance) {
contextOutputTokens: row.context_output_tokens ?? 0,
}))

// Replay last statusline data so reconnecting clients see up-to-date header stats
// (cost, API duration, effort, etc.) without waiting for the next statusline push.
socket.send(JSON.stringify({
type: 'statusline',
statuslineData: {
model: row.model ?? null,
costUsd: row.cost_usd ?? null,
apiDurationMs: row.api_duration_ms ?? null,
linesAdded: row.lines_added ?? null,
linesRemoved: row.lines_removed ?? null,
contextInputTokens: row.context_input_tokens ?? null,
contextOutputTokens: row.context_output_tokens ?? null,
contextWindowSize: row.context_window_size ?? null,
contextPct: row.context_pct ?? null,
effortLevel: row.effort_level ?? null,
thinkingEnabled: row.thinking_enabled !== null ? row.thinking_enabled === 1 : null,
rateLimits: null,
} satisfies StatuslinePayload,
}))

socket.on('message', (raw: Buffer | string) => {
try {
const msg = JSON.parse(raw.toString()) as {
Expand Down
3 changes: 2 additions & 1 deletion frontend/src/App.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom'
import HomeView from './views/HomeView'
import NewSessionView from './views/NewSessionView'
import SessionRoute from './views/SessionRoute'
import SettingsView from './views/SettingsView'
import SessionsTableView from './views/SessionsTableView'
Expand All @@ -14,7 +15,7 @@ export default function App() {
<Routes>
<Route path="/" element={<HomeView />} />
<Route path="/session/:sessionId" element={<SessionRoute />} />
<Route path="/new" element={<Navigate to="/" state={{ openModal: true }} replace />} />
<Route path="/new" element={<NewSessionView />} />
<Route path="/settings" element={<SettingsView />} />
<Route path="/sessions-table" element={<SessionsTableView />} />
<Route path="/account" element={<Navigate to="/" replace />} />
Expand Down
34 changes: 23 additions & 11 deletions frontend/src/components/NewSessionModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,11 @@ import { useState, useEffect } from 'react'
import { FolderOpen } from 'lucide-react'

interface NewSessionModalProps {
onStart: (sessionId: string, workdir: string, name: string | null) => void
onStart: (sessionId: string, workdir: string, name: string | null, mode: 'chat' | 'terminal') => void
onCancel?: () => void
}

export default function NewSessionModal({ onStart }: NewSessionModalProps) {
export default function NewSessionModal({ onStart, onCancel }: NewSessionModalProps) {
const [name, setName] = useState('')
const [workdir, setWorkdir] = useState('')
const [loading, setLoading] = useState(false)
Expand Down Expand Up @@ -73,8 +74,8 @@ export default function NewSessionModal({ onStart }: NewSessionModalProps) {
body: JSON.stringify(body),
})
if (!res.ok) throw new Error(await res.text())
const { sessionId } = (await res.json()) as { sessionId: string }
onStart(sessionId, workdir.trim(), name.trim() || null)
const { sessionId, mode } = (await res.json()) as { sessionId: string; mode: 'chat' | 'terminal' }
onStart(sessionId, workdir.trim(), name.trim() || null, mode)
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to create session')
setLoading(false)
Expand Down Expand Up @@ -142,13 +143,24 @@ export default function NewSessionModal({ onStart }: NewSessionModalProps) {
</div>

{error && <p className="text-status-red text-xs">{error}</p>}
<button
type="submit"
disabled={loading || !workdir.trim()}
className="w-full bg-accent hover:bg-accent-hover disabled:opacity-40 disabled:cursor-not-allowed text-black text-sm font-medium py-2 rounded-md transition-colors"
>
{loading ? 'Starting…' : 'Start session'}
</button>
<div className="flex gap-2">
{onCancel && (
<button
type="button"
onClick={onCancel}
className="flex-1 border border-border text-text-secondary hover:text-text-primary hover:border-text-secondary text-sm font-medium py-2 rounded-md transition-colors"
>
Cancel
</button>
)}
<button
type="submit"
disabled={loading || !workdir.trim()}
className="flex-1 bg-accent hover:bg-accent-hover disabled:opacity-40 disabled:cursor-not-allowed text-black text-sm font-medium py-2 rounded-md transition-colors"
>
{loading ? 'Starting…' : 'Start session'}
</button>
</div>
</form>
</div>
</div>
Expand Down
3 changes: 0 additions & 3 deletions frontend/src/components/SessionHeader.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@ import { useSession } from '../context/SessionContext'
import { formatModelName, formatTokens, formatDuration, formatCost } from '../utils/format'

interface SessionHeaderProps {
onNewSession: () => void
onStopSession: () => void
onSessionsList: () => void
totalTokens: number
Expand Down Expand Up @@ -41,7 +40,6 @@ function StatChip({ label, value, valueClass = 'text-text-secondary' }: StatChip
}

export default function SessionHeader({
onNewSession,
onStopSession,
onSessionsList,
totalTokens,
Expand Down Expand Up @@ -123,7 +121,6 @@ export default function SessionHeader({
<div className="flex items-center gap-2 ml-auto">
<Link
to="/new"
onClick={(e) => { e.preventDefault(); onNewSession() }}
className="flex items-center gap-1.5 text-text-muted hover:text-accent text-xs bg-bg-elevated border border-border-subtle hover:border-accent px-2 py-1 rounded transition-colors"
>
<Plus size={11} />
Expand Down
4 changes: 0 additions & 4 deletions frontend/src/components/SessionList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@ interface SessionListProps {
sessions: Session[]
onStop: (sessionId: string) => void
onDelete: (sessionId: string) => void
onNewSession: () => void
}


Expand All @@ -19,7 +18,6 @@ export default function SessionList({
sessions,
onStop,
onDelete,
onNewSession,
}: SessionListProps) {
async function handleDelete(sessionId: string) {
if (!window.confirm('Delete this session? This cannot be undone.')) return
Expand All @@ -38,7 +36,6 @@ export default function SessionList({
<span className="text-text-muted text-xs uppercase tracking-widest">Sessions</span>
<Link
to="/new"
onClick={(e) => { e.preventDefault(); onNewSession() }}
className="flex items-center gap-1.5 px-3 py-1.5 text-xs bg-accent text-black hover:bg-accent-hover rounded transition-colors"
>
<Plus size={11} />
Expand All @@ -53,7 +50,6 @@ export default function SessionList({
<p className="text-text-muted text-sm">No sessions yet</p>
<Link
to="/new"
onClick={(e) => { e.preventDefault(); onNewSession() }}
className="flex items-center gap-2 px-4 py-2 text-sm bg-accent text-black hover:bg-accent-hover rounded transition-colors"
>
<Plus size={14} />
Expand Down
30 changes: 2 additions & 28 deletions frontend/src/views/HomeView.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { useState, useRef, useCallback, useEffect } from 'react'
import { useLocation, useNavigate } from 'react-router-dom'
import { useNavigate } from 'react-router-dom'
import { ChevronDown, ChevronUp } from 'lucide-react'
import { useSession } from '../context/SessionContext'
import { useWebSocket } from '../hooks/useWebSocket'
Expand All @@ -10,20 +10,17 @@ import SessionHeader from '../components/SessionHeader'
import MessageList from '../components/MessageList'
import ChatInput from '../components/ChatInput'
import TerminalDrawer, { type TerminalDrawerHandle } from '../components/TerminalDrawer'
import NewSessionModal from '../components/NewSessionModal'
import UsageChart from '../components/UsageChart'
import PermissionDialog from '../components/PermissionDialog'
import TerminalSession from '../components/TerminalSession'

export default function HomeView() {
const { state, dispatch } = useSession()
const location = useLocation()
const navigate = useNavigate()
const terminalRef = useRef<TerminalDrawerHandle>(null)
const [showModal, setShowModal] = useState(location.state?.openModal === true)
const [chartOpen, setChartOpen] = useState(false)

const { account, usage, sessions, activeSessions, defaultSessionMode, loading, refresh } = useHomeData()
const { account, usage, sessions, activeSessions, loading, refresh } = useHomeData()
// Local sessions state for optimistic deletion
const [localSessions, setLocalSessions] = useState<Session[] | null>(null)
const displaySessions = localSessions ?? sessions
Expand Down Expand Up @@ -55,22 +52,6 @@ export default function HomeView() {
}, [send, state.mode])


function handleSessionStart(sessionId: string, workdir: string, name: string | null) {
dispatch({ type: 'SESSION_CREATED', sessionId, workdir, mode: defaultSessionMode, ...(name ? { name } : {}) })
if (account?.model) dispatch({ type: 'MODEL_SET', model: account.model })
setShowModal(false)
refresh()
navigate(`/session/${sessionId}`)
}

function handleNewSession() {
if (state.sessionId) {
fetch(`/api/sessions/${state.sessionId}/stop`, { method: 'POST' }).catch(() => {})
}
dispatch({ type: 'SESSION_CLEARED' })
navigate('/new')
}

function handleSessionsList() {
dispatch({ type: 'SESSION_CLEARED' })
navigate('/')
Expand Down Expand Up @@ -175,13 +156,11 @@ export default function HomeView() {
/>
)}
<SessionHeader
onNewSession={handleNewSession}
onStopSession={handleStopSession}
onSessionsList={handleSessionsList}
onRename={handleRenameSession}
sessionName={state.name}
totalTokens={state.totalTokens}

sessionStartedAt={activeSession?.started_at ?? null}
/>
<TerminalSession />
Expand All @@ -199,13 +178,11 @@ export default function HomeView() {
/>
)}
<SessionHeader
onNewSession={handleNewSession}
onStopSession={handleStopSession}
onSessionsList={handleSessionsList}
onRename={handleRenameSession}
sessionName={state.name}
totalTokens={state.totalTokens}

sessionStartedAt={activeSession?.started_at ?? null}
/>
<MessageList messages={state.messages} />
Expand All @@ -215,14 +192,11 @@ export default function HomeView() {
disabled={state.wsState === 'disconnected' || state.wsState === 'error'}
/>
</div>
) : showModal ? (
<NewSessionModal onStart={handleSessionStart} />
) : (
<SessionList
sessions={displaySessions}
onStop={handleStopListSession}
onDelete={handleDelete}
onNewSession={() => setShowModal(true)}
/>
)
)}
Expand Down
31 changes: 31 additions & 0 deletions frontend/src/views/NewSessionView.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import { useEffect, useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { useSession } from '../context/SessionContext'
import NewSessionModal from '../components/NewSessionModal'
import type { AccountInfo } from '../hooks/useAccount'

export default function NewSessionView() {
const { dispatch } = useSession()
const navigate = useNavigate()
const [account, setAccount] = useState<AccountInfo | null>(null)

useEffect(() => {
fetch('/api/account')
.then((r) => (r.ok ? (r.json() as Promise<AccountInfo>) : null))
.then((data) => { if (data) setAccount(data) })
.catch(() => {})
}, [])

function handleStart(sessionId: string, workdir: string, name: string | null, mode: 'chat' | 'terminal') {
dispatch({ type: 'SESSION_CREATED', sessionId, workdir, mode, ...(name ? { name } : {}) })
if (account?.model) dispatch({ type: 'MODEL_SET', model: account.model })
navigate(`/session/${sessionId}`, { replace: true })
}

return (
<NewSessionModal
onStart={handleStart}
onCancel={() => navigate(-1)}
/>
)
}
Loading
Loading