Skip to content
Closed
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
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ import {
setCurrentFastMode,
setCurrentModel,
setCurrentPersonality,
setCurrentProject,
setCurrentProvider,
setCurrentReasoningEffort,
setCurrentServiceTier,
Expand Down Expand Up @@ -196,6 +197,10 @@ export function useGatewayEventHandler(deps: GatewayEventDeps) {
setCurrentBranch(payload.branch)
}

if ('project' in (payload ?? {})) {
Comment thread
danspicytaco marked this conversation as resolved.
setCurrentProject(payload?.project ?? null)
}

if (typeof payload?.personality === 'string') {
setCurrentPersonality(normalizePersonalityValue(payload.personality))
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,8 +33,8 @@ describe('toTodoPayload', () => {

describe('sessionInfoStatePatch / hasSessionInfoStatePatch', () => {
it('extracts only present runtime fields', () => {
const patch = sessionInfoStatePatch(payload({ model: 'gpt', fast: true, branch: 'main' }))
expect(patch).toMatchObject({ model: 'gpt', fast: true, branch: 'main' })
const patch = sessionInfoStatePatch(payload({ model: 'gpt', fast: true, branch: 'main', project: null }))
expect(patch).toMatchObject({ model: 'gpt', fast: true, branch: 'main', project: null })
expect(hasSessionInfoStatePatch(patch)).toBe(true)
expect(hasSessionInfoStatePatch(sessionInfoStatePatch(payload({})))).toBe(false)
})
Expand Down
15 changes: 14 additions & 1 deletion apps/desktop/src/app/session/hooks/use-message-stream/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,16 @@ import type { ClientSessionState } from '../../../types'
type SessionRuntimeStatePatch = Partial<
Pick<
ClientSessionState,
'branch' | 'cwd' | 'fast' | 'model' | 'personality' | 'provider' | 'reasoningEffort' | 'serviceTier' | 'yolo'
| 'branch'
| 'cwd'
| 'fast'
| 'model'
| 'personality'
| 'project'
| 'provider'
| 'reasoningEffort'
| 'serviceTier'
| 'yolo'
>
>

Expand All @@ -29,6 +38,10 @@ export function sessionInfoStatePatch(payload: GatewayEventPayload | undefined):
patch.branch = payload.branch
}

if (payload && Object.hasOwn(payload, 'project')) {
patch.project = payload.project ?? null
}

if (typeof payload?.personality === 'string') {
patch.personality = normalizePersonalityValue(payload.personality)
}
Expand Down
44 changes: 44 additions & 0 deletions apps/desktop/src/app/session/hooks/use-session-actions.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,11 +10,13 @@ import { $projectScope, $projectTree, ALL_PROJECTS } from '@/store/projects'
import {
$activeSessionId,
$currentCwd,
$currentProject,
$messages,
$newChatWorkspaceTarget,
$resumeFailedSessionId,
setActiveSessionId,
setCurrentCwd,
setCurrentProject,
setMessages,
setNewChatWorkspaceTarget,
setResumeFailedSessionId,
Expand All @@ -35,6 +37,7 @@ vi.mock('@/hermes', async importOriginal => ({
}))

const RUNTIME_SESSION_ID = 'rt-new-001'
const PROJECT = { id: 'p_repo', name: 'Repo', primary_path: '/repo', slug: 'repo' }
type HarnessHandle = Pick<
ReturnType<typeof useSessionActions>,
'createBackendSessionForSend' | 'startFreshSessionDraft'
Expand Down Expand Up @@ -256,6 +259,7 @@ describe('resumeSession failure recovery', () => {
setResumeFailedSessionId(null)
setMessages([])
setSessions([])
setCurrentProject(null)
vi.restoreAllMocks()
})

Expand Down Expand Up @@ -381,6 +385,29 @@ describe('resumeSession failure recovery', () => {
expect(resumeParams).toMatchObject({ source: 'desktop' })
})

it('applies project metadata from synchronous resume info', async () => {
setCurrentProject({ id: 'p_old', name: 'Old', primary_path: '/old', slug: 'old' })

const requestGateway = vi.fn(async (method: string, params?: Record<string, unknown>) => {
if (method === 'session.resume') {
return {
session_id: 'runtime-1',
resumed: params?.session_id,
messages: [],
info: { branch: 'main', cwd: '/repo', project: PROJECT }
} as never
}

return {} as never
})

vi.mocked(getSessionMessages).mockResolvedValue({ messages: [] } as never)

await runResume(requestGateway)

expect($currentProject.get()).toEqual(PROJECT)
})

it('arms the failure latch when resume succeeds with an empty transcript for a non-empty stored session', async () => {
setSessions([storedSession({ message_count: 4 })])

Expand Down Expand Up @@ -423,6 +450,7 @@ describe('resumeSession failure recovery', () => {
pendingBranchGroup: null,
personality: '',
provider: '',
project: null,
reasoningEffort: '',
sawAssistantPayload: false,
serviceTier: '',
Expand Down Expand Up @@ -645,6 +673,7 @@ describe('createBackendSessionForSend workspace target', () => {
$newChatProfile.set(null)
$activeGatewayProfile.set('default')
setCurrentCwd('')
setCurrentProject(null)
setNewChatWorkspaceTarget(undefined)
vi.restoreAllMocks()
})
Expand Down Expand Up @@ -677,4 +706,19 @@ describe('createBackendSessionForSend workspace target', () => {

expect(params).toMatchObject({ cwd: '/clicked-workspace' })
})
it('clears the project label when starting a fresh draft', async () => {
const requestGateway = vi.fn(async () => ({}) as never)
let handle: HarnessHandle | null = null

render(<Harness onReady={h => (handle = h)} requestGateway={requestGateway} />)
await waitFor(() => expect(handle).not.toBeNull())

setCurrentProject(PROJECT)

await act(async () => {
handle!.startFreshSessionDraft()
})

expect($currentProject.get()).toBeNull()
})
})
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ import {
setCurrentBranch,
setCurrentCwd,
setCurrentCwdTransient,
setCurrentProject,
setCurrentServiceTier,
setCurrentUsage,
setFreshDraftReady,
Expand Down Expand Up @@ -228,6 +229,7 @@ export function useSessionActions({
}

setCurrentBranch('')
setCurrentProject(null)
// Never clear the composer here — ChatBar's per-thread draft swap owns it.
setFreshDraftReady(true)
},
Expand Down Expand Up @@ -509,6 +511,7 @@ export function useSessionActions({
syncSessionStateToView(cachedRuntimeId, cachedViewState)
setCurrentCwd(cachedViewState.cwd)
setCurrentBranch(cachedViewState.branch)
setCurrentProject(cachedViewState.project)
setSessionStartedAt(Date.now())

try {
Expand Down
18 changes: 17 additions & 1 deletion apps/desktop/src/app/session/hooks/use-session-actions/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import {
setCurrentFastMode,
setCurrentModel,
setCurrentPersonality,
setCurrentProject,
setCurrentProvider,
setCurrentReasoningEffort,
setCurrentServiceTier,
Expand Down Expand Up @@ -255,7 +256,16 @@ export async function resolveStoredSession(storedSessionId: string): Promise<Ses
type SessionRuntimeStatePatch = Partial<
Pick<
ClientSessionState,
'branch' | 'cwd' | 'fast' | 'model' | 'personality' | 'provider' | 'reasoningEffort' | 'serviceTier' | 'yolo'
| 'branch'
| 'cwd'
| 'fast'
| 'model'
| 'personality'
| 'project'
| 'provider'
| 'reasoningEffort'
| 'serviceTier'
| 'yolo'
>
>

Expand Down Expand Up @@ -298,6 +308,11 @@ export function applyRuntimeInfo(info: SessionRuntimeInfo | undefined): SessionR
sessionState.branch = info.branch || ''
}

if (Object.hasOwn(info, 'project')) {
setCurrentProject(info.project ?? null)
sessionState.project = info.project ?? null
}

if (typeof info.personality === 'string') {
const personality = normalizePersonalityValue(info.personality)
setCurrentPersonality(personality)
Expand Down Expand Up @@ -338,6 +353,7 @@ export function applyStoredSessionPreviewRuntimeInfo(stored: { model?: null | st
setCurrentServiceTier('')
setCurrentFastMode(false)
setYoloActive(false)
setCurrentProject(null)
setCurrentPersonality('')
}

Expand Down
2 changes: 2 additions & 0 deletions apps/desktop/src/app/session/hooks/use-session-state-cache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import {
setCurrentFastMode,
setCurrentModel,
setCurrentPersonality,
setCurrentProject,
setCurrentProvider,
setCurrentReasoningEffort,
setCurrentServiceTier,
Expand Down Expand Up @@ -64,6 +65,7 @@ function syncRuntimeMetadataToView(state: ClientSessionState) {
setCurrentFastMode(state.fast ?? false)
setYoloActive(state.yolo ?? false)
setCurrentPersonality(state.personality ?? '')
setCurrentProject(state.project ?? null)
}

export function useSessionStateCache({
Expand Down
8 changes: 6 additions & 2 deletions apps/desktop/src/app/shell/hooks/use-statusbar-items.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import {
$busy,
$connection,
$currentCwd,
$currentProject,
$currentUsage,
$selectedStoredSessionId,
$sessions,
Expand Down Expand Up @@ -91,6 +92,7 @@ export function useStatusbarItems({
const terminalTakeover = useStore($terminalTakeover)
const primaryBusy = useStore($busy)
const currentCwd = useStore($currentCwd)
const currentProject = useStore($currentProject)
const primaryUsage = useStore($currentUsage)
const gatewayRestarting = useStore($gatewayRestarting)
const primarySessionStartedAt = useStore($sessionStartedAt)
Expand Down Expand Up @@ -313,7 +315,7 @@ export function useStatusbarItems({
hidden: !currentCwd,
icon: <FolderOpen className="size-3" />,
id: 'workspace-cwd',
label: currentCwd ? workspaceLabel(currentCwd) : undefined,
label: currentProject?.name || (currentCwd ? workspaceLabel(currentCwd) : undefined),
menuItems: currentCwd
? [
{
Expand All @@ -336,7 +338,7 @@ export function useStatusbarItems({
}
]
: undefined,
title: currentCwd || undefined,
title: currentProject?.primary_path || currentCwd || undefined,
variant: 'menu'
},
{
Expand Down Expand Up @@ -378,6 +380,8 @@ export function useStatusbarItems({
commandCenterOpen,
copy,
currentCwd,
currentProject?.name,
currentProject?.primary_path,
fileMenu.copyPath,
fileMenu.revealFileManager,
fileMenu.revealInSidebar,
Expand Down
3 changes: 2 additions & 1 deletion apps/desktop/src/app/types.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import type * as React from 'react'

import type { ChatMessage } from '@/lib/chat-messages'
import type { UsageStats } from '@/types/hermes'
import type { SessionRuntimeProjectInfo, UsageStats } from '@/types/hermes'

export interface ContextSuggestion {
text: string
Expand Down Expand Up @@ -139,6 +139,7 @@ export interface ClientSessionState {
messages: ChatMessage[]
branch: string
cwd: string
project: SessionRuntimeProjectInfo | null
model: string
provider: string
reasoningEffort: string
Expand Down
3 changes: 2 additions & 1 deletion apps/desktop/src/lib/chat-messages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { dedupeGeneratedImageEchoesInParts } from '@/lib/generated-images'
import { mediaDisplayLabel, mediaMarkdownHref } from '@/lib/media'
import { normalize } from '@/lib/text'
import { parseTodos } from '@/lib/todos'
import type { SessionMessage, UsageStats } from '@/types/hermes'
import type { SessionMessage, SessionRuntimeProjectInfo, UsageStats } from '@/types/hermes'

export type ChatMessagePart = Exclude<ThreadMessageLike['content'], string>[number]

Expand Down Expand Up @@ -51,6 +51,7 @@ export type GatewayEventPayload = {
running?: boolean
cwd?: string
branch?: string
project?: null | SessionRuntimeProjectInfo
credential_warning?: string
install_warning?: string
personality?: string
Expand Down
1 change: 1 addition & 0 deletions apps/desktop/src/lib/chat-runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ export function createClientSessionState(
messages,
branch: '',
cwd: '',
project: null,
model: '',
provider: '',
reasoningEffort: '',
Expand Down
30 changes: 30 additions & 0 deletions apps/desktop/src/lib/markdown-code.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,4 +20,34 @@ describe('isLikelyProseCodeBlock', () => {
it('keeps real code blocks', () => {
expect(isLikelyProseCodeBlock('ts', 'const value = { bunny: true };\nreturn value')).toBe(false)
})

it('keeps zsh command blocks as code even when they look like prose lines', () => {
expect(
isLikelyProseCodeBlock(
'zsh',
[
'cd ~/Documents/dan-personal',
'bash -n install.sh',
'brew bundle check --file=packages/Brewfile',
'env -i HOME="$HOME" USER="$USER" LOGNAME="$LOGNAME" SHELL=/bin/zsh TERM=xterm-256color \\',
" /bin/zsh -lic 'command -v brew && command -v starship && command -v gh && command -v stow'"
].join('\n')
)
).toBe(false)
})

it('keeps text-labeled shell command blocks as code', () => {
expect(
isLikelyProseCodeBlock(
'text',
[
'cd ~/Documents/',
'bash -n install.sh',
'brew bundle check --file=packages/Brewfile',
'env -i HOME="$HOME" USER="$USER" LOGNAME="$LOGNAME" SHELL=/bin/zsh TERM=xterm-256color \\',
"/bin/zsh -lic 'command -v brew && command -v starship && command -v gh && command -v stow'"
].join('\n')
)
).toBe(false)
})
})
Loading