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
2 changes: 1 addition & 1 deletion frontend/src/components/message/MessagePart.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -177,7 +177,7 @@ export const MessagePart = memo(function MessagePart({ part, role, allParts, par
case 'subtask': {
const label = part.description || part.prompt || 'Sub-agent task'
return (
<div className="my-1 w-full rounded border border-purple-500/20 bg-purple-500/5 px-2 py-1 text-left text-xs text-muted-foreground">
<div className="my-1 w-full rounded-lg border border-blue-500/20 bg-blue-500/5 px-3 py-1.5 text-left text-xs text-muted-foreground shadow-sm shadow-blue-500/5">
<div className="flex items-center gap-2 min-w-0">
<span className="truncate">{label}</span>
<span className="ml-auto shrink-0 text-[11px] text-muted-foreground">sub-agent</span>
Expand Down
10 changes: 7 additions & 3 deletions frontend/src/components/message/PromptInput.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -429,9 +429,13 @@ export const PromptInput = memo(forwardRef<PromptInputHandle, PromptInputProps>(
setMentionRange(null)
}

const handleAgentChange = (agent: string) => {
setLocalMode(agent)
setStoredAgent(sessionID, agent)
const handleAgentChange = (agentName: string) => {
setLocalMode(agentName)
setStoredAgent(sessionID, agentName)
const agent = agents.find(a => a.name === agentName)
if (agent?.model) {
setStoredModel({ providerID: agent.model.providerID, modelID: agent.model.modelID })
}
}

const startVoiceRecording = async () => {
Expand Down
42 changes: 32 additions & 10 deletions frontend/src/components/message/ToolCallPart.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { useState, useRef, useEffect } from 'react'
import type { components } from '@/api/opencode-types'
import { useSettings } from '@/hooks/useSettings'
import { useUserBash } from '@/stores/userBashStore'
import { useSessionStatusForSession } from '@/stores/sessionStatusStore'
import { usePermissions, useQuestions } from '@/contexts/EventContext'
import { detectFileReferences } from '@/lib/fileReferences'
import { ExternalLink, Loader2 } from 'lucide-react'
Expand Down Expand Up @@ -68,6 +69,8 @@ function ClickableJson({ json, onFileClick }: { json: unknown; onFileClick?: (fi
export function ToolCallPart({ part, onFileClick, onChildSessionClick }: ToolCallPartProps) {
const { preferences } = useSettings()
const { userBashCommands } = useUserBash()
const taskSessionId = part.tool === 'task' ? getTaskSessionId(part) : undefined
const taskSessionStatus = useSessionStatusForSession(taskSessionId)
const { getForCallID: getPermissionForCallID } = usePermissions()
const { getForCallID: getQuestionForCallID } = useQuestions()
const outputRef = useRef<HTMLDivElement>(null)
Expand Down Expand Up @@ -152,25 +155,44 @@ export function ToolCallPart({ part, onFileClick, onChildSessionClick }: ToolCal
const isFileTool = ['read', 'write', 'edit'].includes(part.tool)

if (part.tool === 'task') {
const sessionId = getTaskSessionId(part)
const sessionId = taskSessionId
const description = previewText || 'Sub-agent task'
const isRunning = part.state.status === 'running' || part.state.status === 'pending'
const status = part.state.status

const isPending = status === 'pending'
const isRunning = status === 'running' && taskSessionStatus.type !== 'idle'
const isCompleted = status === 'completed' || (status === 'running' && !!sessionId && taskSessionStatus.type === 'idle')
const isError = status === 'error'

const content = (
<div className="flex items-center gap-2 min-w-0">
{isRunning ? (
<Loader2 className="w-3.5 h-3.5 animate-spin text-purple-600 dark:text-purple-400" />
) : null}
{isPending && (
<div className="flex gap-1">
<span className="w-2 h-2 rounded-full bg-muted-foreground" />
<span className="w-2 h-2 rounded-full bg-muted-foreground" />
<span className="w-2 h-2 rounded-full bg-muted-foreground" />
</div>
)}
{isRunning && (
<div className="flex gap-1">
<span className="w-2.5 h-2.5 rounded-full bg-blue-500 animate-bounce" style={{ animationDelay: '0ms' }} />
<span className="w-2.5 h-2.5 rounded-full bg-blue-500 animate-bounce" style={{ animationDelay: '150ms' }} />
<span className="w-2.5 h-2.5 rounded-full bg-blue-500 animate-bounce" style={{ animationDelay: '300ms' }} />
</div>
)}
{isCompleted && <span className="text-green-600 text-sm font-medium">✓</span>}
{isError && <span className="text-red-600 text-sm font-medium">✗</span>}
<span className="font-medium text-foreground truncate">{description}</span>
<span className="text-[11px] text-muted-foreground ml-auto shrink-0">sub-agent</span>
{sessionId && <ExternalLink className="w-3 h-3 shrink-0 text-purple-600 dark:text-purple-400" />}
<span className="text-[11px] font-medium text-orange-600 dark:text-orange-400 shrink-0">sub-agent</span>
{sessionId && <ExternalLink className="w-3 h-3 shrink-0 text-blue-600 dark:text-blue-400" />}
</div>
)

if (sessionId) {
return (
<button
onClick={() => onChildSessionClick?.(sessionId)}
className="my-1 w-full rounded border border-purple-500/20 bg-purple-500/5 px-2 py-1 text-left text-xs text-muted-foreground hover:bg-purple-500/10 transition-colors"
className="my-1 w-full rounded-lg border border-blue-500/20 bg-blue-500/5 px-3 py-1.5 text-left text-xs text-muted-foreground hover:bg-blue-500/10 hover:border-blue-500/30 transition-all duration-200 shadow-sm shadow-blue-500/5"
title="View subagent session"
>
{content}
Expand All @@ -179,7 +201,7 @@ export function ToolCallPart({ part, onFileClick, onChildSessionClick }: ToolCal
}

return (
<div className="my-1 rounded border border-purple-500/20 bg-purple-500/5 px-2 py-1 text-xs text-muted-foreground">
<div className="my-1 rounded-lg border border-blue-500/20 bg-blue-500/5 px-3 py-1.5 text-xs text-muted-foreground shadow-sm shadow-blue-500/5">
{content}
</div>
)
Expand Down Expand Up @@ -278,7 +300,7 @@ export function ToolCallPart({ part, onFileClick, onChildSessionClick }: ToolCal
e.stopPropagation()
onChildSessionClick?.(sessionId)
}}
className="text-purple-600 dark:text-purple-400 text-xs hover:text-purple-700 dark:hover:text-purple-300 cursor-pointer underline decoration-dotted flex items-center gap-1"
className="text-blue-600 dark:text-blue-400 text-xs hover:text-blue-700 dark:hover:text-blue-300 cursor-pointer underline decoration-dotted flex items-center gap-1"
title="View subagent session"
>
<ExternalLink className="w-3 h-3" />
Expand Down
8 changes: 6 additions & 2 deletions frontend/src/components/navigation/MoreDrawer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ export function MoreDrawer({ isOpen, onClose }: MoreDrawerProps) {
const [commandsOpen, setCommandsOpen] = useState(false)
const [mentionFileBrowserOpen, setMentionFileBrowserOpen] = useState(false)
const swipeRef = useRef<HTMLDivElement>(null)
const skipHistoryBackOnCloseRef = useRef(false)
const { bind } = useSwipeBack(onClose, { enabled: isOpen, suspendsRouteSwipe: true })
const { logout } = useAuth()
const { data: health } = useServerHealth()
Expand All @@ -55,6 +56,7 @@ export function MoreDrawer({ isOpen, onClose }: MoreDrawerProps) {

useEffect(() => {
if (!isOpen) return
skipHistoryBackOnCloseRef.current = false
let sentinelActive = true
const baseState = window.history.state
const baseUrl = window.location.href
Expand All @@ -68,10 +70,9 @@ export function MoreDrawer({ isOpen, onClose }: MoreDrawerProps) {
return () => {
window.removeEventListener('popstate', onPop)
// Only go back if sentinel is still active AND we haven't navigated away
if (sentinelActive) {
if (sentinelActive && !skipHistoryBackOnCloseRef.current) {
sentinelActive = false
const top = window.history.state as { moreDrawerSentinel?: boolean } | null
// Only go back if the current URL hasn't changed (i.e., no navigation occurred)
if (top?.moreDrawerSentinel && window.location.href === baseUrl) {
window.history.back()
}
Expand All @@ -95,6 +96,7 @@ export function MoreDrawer({ isOpen, onClose }: MoreDrawerProps) {
newParams.delete('mobileTab')
newParams.set('settings', 'open')
newParams.set('tab', 'account')
skipHistoryBackOnCloseRef.current = true
navigate({ search: newParams.toString() }, { replace: true })
onClose()
}
Expand All @@ -109,11 +111,13 @@ export function MoreDrawer({ isOpen, onClose }: MoreDrawerProps) {

const handleItemClick = (item: ReturnType<typeof buildMoreItems>[0]) => {
if (item.to) {
skipHistoryBackOnCloseRef.current = true
navigate(item.to)
} else if (item.dialog) {
const newParams = new URLSearchParams(location.search)
newParams.set('dialog', item.dialog)
newParams.delete('mobileTab')
skipHistoryBackOnCloseRef.current = true
navigate({ search: newParams.toString() }, { replace: true })
}
onClose()
Expand Down
2 changes: 1 addition & 1 deletion frontend/src/components/ui/side-drawer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,7 @@ export function SideDrawer({
/>
<div
className={cn(
'fixed top-0 bottom-0 bg-background border-l border-border pt-safe pb-safe flex flex-col',
'fixed top-0 bottom-0 bg-background border-l border-border pt-safe pb-safe flex flex-col z-50',
side === 'right' ? 'right-0' : 'left-0',
widthClass,
className,
Expand Down
18 changes: 12 additions & 6 deletions frontend/src/hooks/useContextUsage.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import { useMemo } from 'react'
import { useMessages } from './useOpenCode'
import { useQuery } from '@tanstack/react-query'
import { useModelSelection } from './useModelSelection'
import { fetchWrapper } from '@/api/fetchWrapper'

interface ContextUsage {
Expand Down Expand Up @@ -39,8 +38,6 @@ async function fetchProviders(opcodeUrl: string): Promise<ProvidersResponse> {

export const useContextUsage = (opcodeUrl: string | null | undefined, sessionID: string | undefined, directory?: string): ContextUsage => {
const { data: messages, isLoading: messagesLoading } = useMessages(opcodeUrl, sessionID, directory)
const { modelString: globalModelString } = useModelSelection(opcodeUrl, directory)
const modelString = globalModelString

const { data: providersData } = useQuery({
queryKey: ['providers', opcodeUrl],
Expand All @@ -53,8 +50,6 @@ export const useContextUsage = (opcodeUrl: string | null | undefined, sessionID:
})

return useMemo(() => {
const currentModel = modelString || null

const assistantMessages = messages?.filter(msg => msg.info.role === 'assistant') || []
let latestAssistantMessage = assistantMessages[assistantMessages.length - 1]

Expand All @@ -66,6 +61,17 @@ export const useContextUsage = (opcodeUrl: string | null | undefined, sessionID:
}
}

const currentModel = (() => {
if (!latestAssistantMessage || latestAssistantMessage.info.role !== 'assistant') {
return null
}
const msg = latestAssistantMessage.info as { providerID?: string; modelID?: string }
if (msg.providerID && msg.modelID) {
return `${msg.providerID}/${msg.modelID}`
}
return null
})()

let contextLimit: number | null = null
if (currentModel && providersData) {
const [providerId, modelId] = currentModel.split('/')
Expand Down Expand Up @@ -103,5 +109,5 @@ export const useContextUsage = (opcodeUrl: string | null | undefined, sessionID:
currentModel,
isLoading: false
}
}, [messages, messagesLoading, modelString, providersData])
}, [messages, messagesLoading, providersData])
}
2 changes: 1 addition & 1 deletion frontend/src/pages/SessionDetail.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -510,7 +510,7 @@ export function SessionDetail() {
</div>
</div>

<div className="flex-1 overflow-hidden flex flex-col">
<div className="relative flex-1 overflow-hidden flex flex-col">
<div key={sessionId} ref={messageContainerRef} className="flex-1 overflow-y-auto overflow-x-hidden overscroll-contain [mask-image:linear-gradient(to_bottom,transparent,black_16px,black)]" style={{ paddingBottom: promptOverlayHeight + inputBottomOffset + 16 }}>
{repoLoading || assistantModeLoading || sessionLoading || messagesLoading ? (
<MessageSkeleton />
Expand Down
19 changes: 19 additions & 0 deletions frontend/src/stores/modelStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,13 +9,16 @@ export interface ModelSelection {

interface ModelStore {
model: ModelSelection | null
agentModels: Record<string, ModelSelection>
recentModels: ModelSelection[]
favoriteModels: ModelSelection[]
variants: Record<string, string | undefined>
lastConfigModel: string | undefined

setModel: (model: ModelSelection) => void
setActiveModel: (model: ModelSelection) => void
setAgentModel: (agent: string, model: ModelSelection) => void
getAgentModel: (agent: string) => ModelSelection | null
syncModelState: (state: { recent: ModelSelection[], favorite: ModelSelection[], variant: Record<string, string | undefined> }) => void
toggleFavorite: (model: ModelSelection) => void
syncFromConfig: (configModel: string | undefined, force?: boolean) => void
Expand All @@ -39,6 +42,7 @@ export const useModelStore = create<ModelStore>()(
persist(
(set, get) => ({
model: null,
agentModels: {},
recentModels: [],
favoriteModels: [],
variants: {},
Expand All @@ -64,6 +68,20 @@ export const useModelStore = create<ModelStore>()(
set({ model })
},

setAgentModel: (agent: string, model: ModelSelection) => {
set((state) => ({
agentModels: {
...state.agentModels,
[agent]: model,
},
}))
},

getAgentModel: (agent: string) => {
const state = get()
return state.agentModels[agent] ?? null
},

syncModelState: (modelState) => {
set((state) => ({
recentModels: modelState.recent,
Expand Down Expand Up @@ -182,6 +200,7 @@ export const useModelStore = create<ModelStore>()(
name: 'opencode-model-selection',
partialize: (state) => ({
model: state.model,
agentModels: state.agentModels,
recentModels: state.recentModels,
favoriteModels: state.favoriteModels,
variants: state.variants,
Expand Down
Loading