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
1 change: 1 addition & 0 deletions bin/cli.js
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ const child = spawn(electron, [mainPath, ...args], {
})

// Forward signals to child process
// eslint-disable-next-line @typescript-eslint/explicit-function-return-type
function forwardSignal(signal) {
if (child.pid) {
process.kill(child.pid, signal)
Expand Down
4 changes: 2 additions & 2 deletions electron.vite.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,10 @@ import tailwindcss from '@tailwindcss/vite'
const pkg = JSON.parse(readFileSync('./package.json', 'utf-8'))

// Plugin to copy resources to output
function copyResources() {
function copyResources(): { name: string; closeBundle: () => void } {
return {
name: 'copy-resources',
closeBundle() {
closeBundle(): void {
const srcIcon = resolve('resources/icon.png')
const destDir = resolve('out/resources')
const destIcon = resolve('out/resources/icon.png')
Expand Down
4 changes: 2 additions & 2 deletions src/main/ipc/threads.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import { deleteThreadCheckpoint } from '../storage'
import { generateTitle } from '../services/title-generator'
import type { Thread } from '../types'

export function registerThreadHandlers(ipcMain: IpcMain) {
export function registerThreadHandlers(ipcMain: IpcMain): void {
// List all threads
ipcMain.handle('threads:list', async () => {
const threads = getAllThreads()
Expand Down Expand Up @@ -90,7 +90,7 @@ export function registerThreadHandlers(ipcMain: IpcMain) {
// Delete a thread
ipcMain.handle('threads:delete', async (_event, threadId: string) => {
console.log('[Threads] Deleting thread:', threadId)

// Delete from our metadata store
dbDeleteThread(threadId)
console.log('[Threads] Deleted from metadata store')
Expand Down
2 changes: 1 addition & 1 deletion src/main/storage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,7 @@ function parseEnvFile(): Record<string, string> {
function writeEnvFile(env: Record<string, string>): void {
getOpenworkDir() // ensure dir exists
const lines = Object.entries(env)
.filter(([_, v]) => v)
.filter((entry) => entry[1])
.map(([k, v]) => `${k}=${v}`)
writeFileSync(getEnvFilePath(), lines.join('\n') + '\n')
}
Expand Down
12 changes: 6 additions & 6 deletions src/renderer/src/components/chat/ApiKeyDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,13 +24,13 @@ const PROVIDER_INFO: Record<string, { placeholder: string; envVar: string }> = {
google: { placeholder: 'AIza...', envVar: 'GOOGLE_API_KEY' }
}

export function ApiKeyDialog({ open, onOpenChange, provider }: ApiKeyDialogProps) {
export function ApiKeyDialog({ open, onOpenChange, provider }: ApiKeyDialogProps): React.JSX.Element | null {
const [apiKey, setApiKey] = useState('')
const [showKey, setShowKey] = useState(false)
const [saving, setSaving] = useState(false)
const [deleting, setDeleting] = useState(false)
const [hasExistingKey, setHasExistingKey] = useState(false)

const { setApiKey: saveApiKey, deleteApiKey } = useAppStore()

// Check if there's an existing key when dialog opens
Expand All @@ -46,10 +46,10 @@ export function ApiKeyDialog({ open, onOpenChange, provider }: ApiKeyDialogProps

const info = PROVIDER_INFO[provider.id] || { placeholder: '...', envVar: '' }

async function handleSave() {
async function handleSave(): Promise<void> {
if (!apiKey.trim()) return
if (!provider) return

console.log('[ApiKeyDialog] Saving API key for provider:', provider.id)
setSaving(true)
try {
Expand All @@ -63,7 +63,7 @@ export function ApiKeyDialog({ open, onOpenChange, provider }: ApiKeyDialogProps
}
}

async function handleDelete() {
async function handleDelete(): Promise<void> {
if (!provider) return
setDeleting(true)
try {
Expand All @@ -84,7 +84,7 @@ export function ApiKeyDialog({ open, onOpenChange, provider }: ApiKeyDialogProps
{hasExistingKey ? `Update ${provider.name} API Key` : `Add ${provider.name} API Key`}
</DialogTitle>
<DialogDescription>
{hasExistingKey
{hasExistingKey
? 'Enter a new API key to replace the existing one, or remove it.'
: `Enter your ${provider.name} API key to use their models.`
}
Expand Down
17 changes: 9 additions & 8 deletions src/renderer/src/components/chat/ChatContainer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,8 @@ import { useCurrentThread, useThreadStream } from '@/lib/thread-context'
import { MessageBubble } from './MessageBubble'
import { ModelSwitcher } from './ModelSwitcher'
import { Folder } from 'lucide-react'
import { WorkspacePicker, selectWorkspaceFolder } from './WorkspacePicker'
import { WorkspacePicker } from './WorkspacePicker'
import { selectWorkspaceFolder } from '@/lib/workspace-utils'
import { ChatTodos } from './ChatTodos'
import { ContextUsageIndicator } from './ContextUsageIndicator'
import type { Message } from '@/types'
Expand Down Expand Up @@ -61,7 +62,7 @@ export function ChatContainer({ threadId }: ChatContainerProps): React.JSX.Eleme
const isLoading = streamData.isLoading

const handleApprovalDecision = useCallback(
async (decision: 'approve' | 'reject' | 'edit') => {
async (decision: 'approve' | 'reject' | 'edit'): Promise<void> => {
if (!pendingApproval || !stream) return

setPendingApproval(null)
Expand Down Expand Up @@ -163,14 +164,14 @@ export function ChatContainer({ threadId }: ChatContainerProps): React.JSX.Eleme
}, [displayMessages])

// Get the actual scrollable viewport element from Radix ScrollArea
const getViewport = useCallback(() => {
const getViewport = useCallback((): HTMLDivElement | null => {
return scrollRef.current?.querySelector(
'[data-radix-scroll-area-viewport]'
) as HTMLDivElement | null
}, [])

// Track scroll position to determine if user is at bottom
const handleScroll = useCallback(() => {
const handleScroll = useCallback((): void => {
const viewport = getViewport()
if (!viewport) return

Expand Down Expand Up @@ -286,7 +287,7 @@ export function ChatContainer({ threadId }: ChatContainerProps): React.JSX.Eleme
}

const handleSelectWorkspaceFromEmptyState = async (): Promise<void> => {
await selectWorkspaceFolder(threadId, setWorkspacePath, setWorkspaceFiles, () => {}, undefined)
await selectWorkspaceFolder(threadId, setWorkspacePath, setWorkspaceFiles, () => { }, undefined)
}

return (
Expand Down Expand Up @@ -322,9 +323,9 @@ export function ChatContainer({ threadId }: ChatContainerProps): React.JSX.Eleme
)}

{displayMessages.map((message) => (
<MessageBubble
key={message.id}
message={message}
<MessageBubble
key={message.id}
message={message}
toolResults={toolResults}
pendingApproval={pendingApproval}
onApprovalDecision={handleApprovalDecision}
Expand Down
15 changes: 10 additions & 5 deletions src/renderer/src/components/chat/ContextUsageIndicator.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,11 @@ const MODEL_CONTEXT_LIMITS: Record<string, number> = {
'o3': 200_000,
'o3-mini': 200_000,
// Google models
'gemini-3-pro-preview': 2_000_000,
'gemini-3-flash-preview': 1_000_000,
'gemini-2.5-pro': 2_000_000,
'gemini-2.5-flash': 1_000_000,
'gemini-2.5-flash-lite': 1_000_000,
'gemini-2.0-flash': 1_000_000,
'gemini-1.5-pro': 2_000_000,
'gemini-1.5-flash': 1_000_000
Expand Down Expand Up @@ -131,8 +136,8 @@ export function ContextUsageIndicator({
</span>
</button>
</PopoverTrigger>
<PopoverContent
className="w-72 p-0 bg-background border-border"
<PopoverContent
className="w-72 p-0 bg-background border-border"
align="end"
sideOffset={8}
>
Expand All @@ -148,7 +153,7 @@ export function ContextUsageIndicator({
{/* Progress bar */}
<div className="space-y-1">
<div className="h-2 bg-muted rounded-full overflow-hidden">
<div
<div
className={cn('h-full rounded-full transition-all', barColorClass)}
style={{ width: `${usagePercent}%` }}
/>
Expand All @@ -164,7 +169,7 @@ export function ContextUsageIndicator({
<div className="text-[10px] font-medium text-muted-foreground uppercase tracking-wider">
Token Breakdown
</div>

<div className="space-y-1">
{/* Input tokens */}
<div className="flex items-center justify-between text-xs">
Expand Down Expand Up @@ -201,7 +206,7 @@ export function ContextUsageIndicator({
<div className="text-[10px] font-medium text-muted-foreground uppercase tracking-wider">
Cache
</div>

<div className="space-y-1">
{tokenUsage.cacheReadTokens !== undefined && tokenUsage.cacheReadTokens > 0 && (
<div className="flex items-center justify-between text-xs">
Expand Down
8 changes: 4 additions & 4 deletions src/renderer/src/components/chat/MessageBubble.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ interface MessageBubbleProps {
onApprovalDecision?: (decision: 'approve' | 'reject' | 'edit') => void
}

export function MessageBubble({ message, isStreaming, toolResults, pendingApproval, onApprovalDecision }: MessageBubbleProps) {
export function MessageBubble({ message, isStreaming, toolResults, pendingApproval, onApprovalDecision }: MessageBubbleProps): React.JSX.Element | null {
const isUser = message.role === 'user'
const isTool = message.role === 'tool'

Expand All @@ -26,17 +26,17 @@ export function MessageBubble({ message, isStreaming, toolResults, pendingApprov
return null
}

const getIcon = () => {
const getIcon = (): React.JSX.Element => {
if (isUser) return <User className="size-4" />
return <Bot className="size-4" />
}

const getLabel = () => {
const getLabel = (): string => {
if (isUser) return 'YOU'
return 'AGENT'
}

const renderContent = () => {
const renderContent = (): React.ReactNode => {
if (typeof message.content === 'string') {
// Empty content
if (!message.content.trim()) {
Expand Down
59 changes: 25 additions & 34 deletions src/renderer/src/components/chat/ModelSwitcher.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,26 +9,26 @@ import { ApiKeyDialog } from './ApiKeyDialog'
import type { Provider, ProviderId } from '@/types'

// Provider icons as simple SVG components
function AnthropicIcon({ className }: { className?: string }) {
function AnthropicIcon({ className }: { className?: string }): React.JSX.Element {
return (
<svg className={className} viewBox="0 0 24 24" fill="currentColor">
<path d="M17.304 3.541h-3.672l6.696 16.918h3.672l-6.696-16.918zm-10.608 0L0 20.459h3.744l1.368-3.562h7.044l1.368 3.562h3.744L10.608 3.541H6.696zm.576 10.852l2.352-6.122 2.352 6.122H7.272z"/>
<path d="M17.304 3.541h-3.672l6.696 16.918h3.672l-6.696-16.918zm-10.608 0L0 20.459h3.744l1.368-3.562h7.044l1.368 3.562h3.744L10.608 3.541H6.696zm.576 10.852l2.352-6.122 2.352 6.122H7.272z" />
</svg>
)
}

function OpenAIIcon({ className }: { className?: string }) {
function OpenAIIcon({ className }: { className?: string }): React.JSX.Element {
return (
<svg className={className} viewBox="0 0 24 24" fill="currentColor">
<path d="M22.282 9.821a5.985 5.985 0 0 0-.516-4.91 6.046 6.046 0 0 0-6.51-2.9A6.065 6.065 0 0 0 4.981 4.18a5.985 5.985 0 0 0-3.998 2.9 6.046 6.046 0 0 0 .743 7.097 5.98 5.98 0 0 0 .51 4.911 6.051 6.051 0 0 0 6.515 2.9A5.985 5.985 0 0 0 13.26 24a6.056 6.056 0 0 0 5.772-4.206 5.99 5.99 0 0 0 3.997-2.9 6.056 6.056 0 0 0-.747-7.073zM13.26 22.43a4.476 4.476 0 0 1-2.876-1.04l.141-.081 4.779-2.758a.795.795 0 0 0 .392-.681v-6.737l2.02 1.168a.071.071 0 0 1 .038.052v5.583a4.504 4.504 0 0 1-4.494 4.494zM3.6 18.304a4.47 4.47 0 0 1-.535-3.014l.142.085 4.783 2.759a.771.771 0 0 0 .78 0l5.843-3.369v2.332a.08.08 0 0 1-.033.062L9.74 19.95a4.5 4.5 0 0 1-6.14-1.646zM2.34 7.896a4.485 4.485 0 0 1 2.366-1.973V11.6a.766.766 0 0 0 .388.676l5.815 3.355-2.02 1.168a.076.076 0 0 1-.071 0l-4.83-2.786A4.504 4.504 0 0 1 2.34 7.872zm16.597 3.855l-5.833-3.387L15.119 7.2a.076.076 0 0 1 .071 0l4.83 2.791a4.494 4.494 0 0 1-.676 8.105v-5.678a.79.79 0 0 0-.407-.667zm2.01-3.023l-.141-.085-4.774-2.782a.776.776 0 0 0-.785 0L9.409 9.23V6.897a.066.066 0 0 1 .028-.061l4.83-2.787a4.5 4.5 0 0 1 6.68 4.66zm-12.64 4.135l-2.02-1.164a.08.08 0 0 1-.038-.057V6.075a4.5 4.5 0 0 1 7.375-3.453l-.142.08L8.704 5.46a.795.795 0 0 0-.393.681zm1.097-2.365l2.602-1.5 2.607 1.5v2.999l-2.597 1.5-2.607-1.5z"/>
<path d="M22.282 9.821a5.985 5.985 0 0 0-.516-4.91 6.046 6.046 0 0 0-6.51-2.9A6.065 6.065 0 0 0 4.981 4.18a5.985 5.985 0 0 0-3.998 2.9 6.046 6.046 0 0 0 .743 7.097 5.98 5.98 0 0 0 .51 4.911 6.051 6.051 0 0 0 6.515 2.9A5.985 5.985 0 0 0 13.26 24a6.056 6.056 0 0 0 5.772-4.206 5.99 5.99 0 0 0 3.997-2.9 6.056 6.056 0 0 0-.747-7.073zM13.26 22.43a4.476 4.476 0 0 1-2.876-1.04l.141-.081 4.779-2.758a.795.795 0 0 0 .392-.681v-6.737l2.02 1.168a.071.071 0 0 1 .038.052v5.583a4.504 4.504 0 0 1-4.494 4.494zM3.6 18.304a4.47 4.47 0 0 1-.535-3.014l.142.085 4.783 2.759a.771.771 0 0 0 .78 0l5.843-3.369v2.332a.08.08 0 0 1-.033.062L9.74 19.95a4.5 4.5 0 0 1-6.14-1.646zM2.34 7.896a4.485 4.485 0 0 1 2.366-1.973V11.6a.766.766 0 0 0 .388.676l5.815 3.355-2.02 1.168a.076.076 0 0 1-.071 0l-4.83-2.786A4.504 4.504 0 0 1 2.34 7.872zm16.597 3.855l-5.833-3.387L15.119 7.2a.076.076 0 0 1 .071 0l4.83 2.791a4.494 4.494 0 0 1-.676 8.105v-5.678a.79.79 0 0 0-.407-.667zm2.01-3.023l-.141-.085-4.774-2.782a.776.776 0 0 0-.785 0L9.409 9.23V6.897a.066.066 0 0 1 .028-.061l4.83-2.787a4.5 4.5 0 0 1 6.68 4.66zm-12.64 4.135l-2.02-1.164a.08.08 0 0 1-.038-.057V6.075a4.5 4.5 0 0 1 7.375-3.453l-.142.08L8.704 5.46a.795.795 0 0 0-.393.681zm1.097-2.365l2.602-1.5 2.607 1.5v2.999l-2.597 1.5-2.607-1.5z" />
</svg>
)
}

function GoogleIcon({ className }: { className?: string }) {
function GoogleIcon({ className }: { className?: string }): React.JSX.Element {
return (
<svg className={className} viewBox="0 0 24 24" fill="currentColor">
<path d="M20.616 10.835a14.147 14.147 0 01-4.45-3.001 14.111 14.111 0 01-3.678-6.452.503.503 0 00-.975 0 14.134 14.134 0 01-3.679 6.452 14.155 14.155 0 01-4.45 3.001c-.65.28-1.318.505-2.002.678a.502.502 0 000 .975c.684.172 1.35.397 2.002.677a14.147 14.147 0 014.45 3.001 14.112 14.112 0 013.679 6.453.502.502 0 00.975 0c.172-.685.397-1.351.677-2.003a14.145 14.145 0 013.001-4.45 14.113 14.113 0 016.453-3.678.503.503 0 000-.975 13.245 13.245 0 01-2.003-.678z"/>
<path d="M20.616 10.835a14.147 14.147 0 01-4.45-3.001 14.111 14.111 0 01-3.678-6.452.503.503 0 00-.975 0 14.134 14.134 0 01-3.679 6.452 14.155 14.155 0 01-4.45 3.001c-.65.28-1.318.505-2.002.678a.502.502 0 000 .975c.684.172 1.35.397 2.002.677a14.147 14.147 0 014.45 3.001 14.112 14.112 0 013.679 6.453.502.502 0 00.975 0c.172-.685.397-1.351.677-2.003a14.145 14.145 0 013.001-4.45 14.113 14.113 0 016.453-3.678.503.503 0 000-.975 13.245 13.245 0 01-2.003-.678z" />
</svg>
)
}
Expand All @@ -51,12 +51,12 @@ interface ModelSwitcherProps {
threadId: string
}

export function ModelSwitcher({ threadId }: ModelSwitcherProps) {
export function ModelSwitcher({ threadId }: ModelSwitcherProps): React.JSX.Element {
const [open, setOpen] = useState(false)
const [selectedProviderId, setSelectedProviderId] = useState<ProviderId | null>(null)
const [apiKeyDialogOpen, setApiKeyDialogOpen] = useState(false)
const [apiKeyProvider, setApiKeyProvider] = useState<Provider | null>(null)

const { models, providers, loadModels, loadProviders } = useAppStore()
const { currentModel, setCurrentModel } = useCurrentThread(threadId)

Expand All @@ -69,41 +69,32 @@ export function ModelSwitcher({ threadId }: ModelSwitcherProps) {
// Use fallback providers if none loaded
const displayProviders = providers.length > 0 ? providers : FALLBACK_PROVIDERS

// Set initial selected provider based on current model
useEffect(() => {
if (!selectedProviderId && currentModel) {
const model = models.find(m => m.id === currentModel)
if (model) {
setSelectedProviderId(model.provider)
}
}
// Default to first provider if none selected
if (!selectedProviderId && displayProviders.length > 0) {
setSelectedProviderId(displayProviders[0].id)
}
}, [currentModel, models, selectedProviderId, displayProviders])
// Determine effective provider ID (manual selection > current model > default)
const effectiveProviderId = selectedProviderId ||
(currentModel ? models.find(m => m.id === currentModel)?.provider : null) ||
(displayProviders.length > 0 ? displayProviders[0].id : null)

const selectedModel = models.find(m => m.id === currentModel)
const filteredModels = selectedProviderId
? models.filter(m => m.provider === selectedProviderId)
const filteredModels = effectiveProviderId
? models.filter(m => m.provider === effectiveProviderId)
: []
const selectedProvider = displayProviders.find(p => p.id === selectedProviderId)
const selectedProvider = displayProviders.find(p => p.id === effectiveProviderId)

function handleProviderClick(provider: Provider) {
function handleProviderClick(provider: Provider): void {
setSelectedProviderId(provider.id)
}

function handleModelSelect(modelId: string) {
function handleModelSelect(modelId: string): void {
setCurrentModel(modelId)
setOpen(false)
}

function handleConfigureApiKey(provider: Provider) {
function handleConfigureApiKey(provider: Provider): void {
setApiKeyProvider(provider)
setApiKeyDialogOpen(true)
}

function handleApiKeyDialogClose(isOpen: boolean) {
function handleApiKeyDialogClose(isOpen: boolean): void {
setApiKeyDialogOpen(isOpen)
if (!isOpen) {
// Refresh providers after dialog closes
Expand Down Expand Up @@ -132,8 +123,8 @@ export function ModelSwitcher({ threadId }: ModelSwitcherProps) {
<ChevronDown className="size-3" />
</Button>
</PopoverTrigger>
<PopoverContent
className="w-[420px] p-0 bg-background border-border"
<PopoverContent
className="w-[420px] p-0 bg-background border-border"
align="start"
sideOffset={8}
>
Expand All @@ -152,7 +143,7 @@ export function ModelSwitcher({ threadId }: ModelSwitcherProps) {
onClick={() => handleProviderClick(provider)}
className={cn(
"w-full flex items-center gap-1.5 px-2 py-1 rounded-sm text-xs transition-colors text-left",
selectedProviderId === provider.id
effectiveProviderId === provider.id
? "bg-muted text-foreground"
: "text-muted-foreground hover:text-foreground hover:bg-muted/50"
)}
Expand All @@ -173,7 +164,7 @@ export function ModelSwitcher({ threadId }: ModelSwitcherProps) {
<div className="text-[10px] font-medium text-muted-foreground uppercase tracking-wider px-2 py-1.5">
Model
</div>

{selectedProvider && !selectedProvider.hasApiKey ? (
// No API key configured
<div className="flex flex-col items-center justify-center h-[180px] px-4 text-center">
Expand Down Expand Up @@ -209,14 +200,14 @@ export function ModelSwitcher({ threadId }: ModelSwitcherProps) {
)}
</button>
))}

{filteredModels.length === 0 && (
<p className="text-xs text-muted-foreground px-2 py-4">
No models available
</p>
)}
</div>

{/* Configure API key link for providers that have a key */}
{selectedProvider?.hasApiKey && (
<button
Expand Down
2 changes: 1 addition & 1 deletion src/renderer/src/components/chat/StreamingMarkdown.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ interface StreamingMarkdownProps {
export const StreamingMarkdown = memo(function StreamingMarkdown({
children,
isStreaming = false
}: StreamingMarkdownProps) {
}: StreamingMarkdownProps): React.JSX.Element {
return (
<div className="streaming-markdown">
<ReactMarkdown remarkPlugins={[remarkGfm]}>
Expand Down
Loading
Loading