Skip to content
Open
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
49 changes: 49 additions & 0 deletions packages/app/src/components/session-task-indicator.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import { createMemo, Show } from "solid-js"
import { Button } from "@opencode-ai/ui/button"
import { useSync } from "@/context/sync"
import { useLayout } from "@/context/layout"
import { useParams } from "@solidjs/router"

export function SessionTaskIndicator() {
const sync = useSync()
const layout = useLayout()
const params = useParams()

const taskStats = createMemo(() => {
const sessionID = params.id
if (!sessionID) return { completed: 0, total: 0, hasInProgress: false, hasIncomplete: false }
const todos = sync.data.todo[sessionID] ?? []
const completed = todos.filter((t) => t.status === "completed").length
const hasInProgress = todos.some((t) => t.status === "in_progress")
const hasIncomplete = todos.some((t) => t.status !== "completed")
return { completed, total: todos.length, hasInProgress, hasIncomplete }
})

const handleClick = () => {
const sessionKey = `${params.dir}${params.id ? "/" + params.id : ""}`
const tabs = layout.tabs(sessionKey)
if (tabs.active() === "tasks") {
layout.review.close()
return
}
if (!layout.review.opened()) layout.review.open()
tabs.setActive("tasks")
}

return (
<Show when={taskStats().hasIncomplete}>
<Button variant="ghost" onClick={handleClick}>
<div
classList={{
"size-1.5 rounded-full": true,
"bg-icon-warning-base animate-pulse": taskStats().hasInProgress,
"bg-icon-weak": !taskStats().hasInProgress,
}}
/>
<span class="text-12-regular text-text-weak">
{taskStats().completed}/{taskStats().total} Tasks
</span>
</Button>
</Show>
)
}
90 changes: 90 additions & 0 deletions packages/app/src/components/session-task-panel.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
import { createMemo, For, Show } from "solid-js"
import { useSync } from "@/context/sync"
import { useParams } from "@solidjs/router"
import { Icon } from "@opencode-ai/ui/icon"
import type { Todo } from "@opencode-ai/sdk/v2/client"

function TaskItem(props: { todo: Todo }) {
return (
<div
classList={{
"flex items-start gap-3 p-3 rounded-lg border transition-colors": true,
"border-border-base bg-surface-base": props.todo.status === "pending",
"border-border-warning-base bg-surface-warning-base/10": props.todo.status === "in_progress",
"border-border-success-base/50 bg-surface-success-base/5": props.todo.status === "completed",
"border-border-weak-base bg-surface-weak-base/50 opacity-60": props.todo.status === "cancelled",
}}
>
<div
classList={{
"mt-0.5 shrink-0": true,
"text-icon-success-base": props.todo.status === "completed",
"text-icon-warning-base": props.todo.status === "in_progress",
"text-icon-weak": props.todo.status === "cancelled" || props.todo.status === "pending",
}}
>
<Show
when={props.todo.status === "completed"}
fallback={
<Show
when={props.todo.status === "in_progress"}
fallback={
<Show
when={props.todo.status === "cancelled"}
fallback={<Icon name="circle-check" size="small" class="opacity-30" />}
>
<Icon name="close" size="small" />
</Show>
}
>
<Icon name="dot-grid" size="small" class="animate-pulse" />
</Show>
}
>
<Icon name="check" size="small" />
</Show>
</div>
<div class="flex-1 min-w-0">
<span
classList={{
"text-14-regular": true,
"text-text-base": props.todo.status !== "cancelled" && props.todo.status !== "completed",
"text-text-weak line-through": props.todo.status === "cancelled",
"text-text-weak": props.todo.status === "completed",
}}
>
{props.todo.content}
</span>
</div>
</div>
)
}

export function SessionTaskPanel() {
const sync = useSync()
const params = useParams()

const todos = createMemo(() => {
const sessionID = params.id
if (!sessionID) return []
return sync.data.todo[sessionID] ?? []
})

return (
<div class="h-full flex flex-col overflow-hidden">
<div class="flex-1 overflow-y-auto px-6 py-4">
<Show when={todos().length === 0}>
<div class="flex flex-col items-center justify-center h-full text-text-weak">
<Icon name="checklist" size="large" class="mb-2 opacity-50" />
<span class="text-14-regular">No tasks yet</span>
</div>
</Show>
<Show when={todos().length > 0}>
<div class="flex flex-col gap-2">
<For each={todos()}>{(todo) => <TaskItem todo={todo} />}</For>
</div>
</Show>
</div>
</div>
)
}
15 changes: 13 additions & 2 deletions packages/app/src/components/session/session-header.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import { TextField } from "@opencode-ai/ui/text-field"
import { DialogSelectServer } from "@/components/dialog-select-server"
import { SessionLspIndicator } from "@/components/session-lsp-indicator"
import { SessionMcpIndicator } from "@/components/session-mcp-indicator"
import { SessionTaskIndicator } from "@/components/session-task-indicator"
import type { Session } from "@opencode-ai/sdk/v2/client"
import { same } from "@/utils/same"

Expand All @@ -44,6 +45,15 @@ export function SessionHeader() {
const shareEnabled = createMemo(() => sync.data.config.share !== "disabled")
const worktrees = createMemo(() => layout.projects.list().map((p) => p.worktree), [], { equals: same })

const hasIncompleteTasks = createMemo(() => {
const sessionID = params.id
if (!sessionID) return false
const todos = sync.data.todo[sessionID] ?? []
return todos.some((t) => t.status !== "completed")
})

const showSidebarToggle = createMemo(() => currentSession()?.summary?.files || hasIncompleteTasks())

function navigateToProject(directory: string) {
navigate(`/${base64Encode(directory)}`)
}
Expand Down Expand Up @@ -163,12 +173,13 @@ export function SessionHeader() {
</Button>
<SessionLspIndicator />
<SessionMcpIndicator />
<SessionTaskIndicator />
</div>
<div class="flex items-center gap-1">
<Show when={currentSession()?.summary?.files}>
<Show when={showSidebarToggle()}>
<TooltipKeybind
class="hidden md:block shrink-0"
title="Toggle review"
title="Toggle sidebar"
keybind={command.keybind("review.toggle")}
>
<Button variant="ghost" class="group/review-toggle size-6 p-0" onClick={layout.review.toggle}>
Expand Down
2 changes: 2 additions & 0 deletions packages/app/src/context/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
opened: true,
diffStyle: "split" as ReviewDiffStyle,
},

session: {
width: 600,
},
Expand Down Expand Up @@ -341,6 +342,7 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
setStore("review", "opened", (x) => !x)
},
},

session: {
width: createMemo(() => store.session?.width ?? 600),
resize(width: number) {
Expand Down
39 changes: 36 additions & 3 deletions packages/app/src/pages/session.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ import {
SortableTerminalTab,
NewSessionView,
} from "@/components/session"
import { SessionTaskPanel } from "@/components/session-task-panel"
import { usePlatform } from "@/context/platform"
import { same } from "@/utils/same"

Expand Down Expand Up @@ -640,14 +641,23 @@ export default function Page() {
const openedTabs = createMemo(() =>
tabs()
.all()
.filter((tab) => tab !== "context"),
.filter((tab) => tab !== "context" && tab !== "tasks"),
)

const reviewTab = createMemo(() => diffs().length > 0 || tabs().active() === "review")
const mobileReview = createMemo(() => !isDesktop() && diffs().length > 0 && store.mobileTab === "review")

const hasIncompleteTasks = createMemo(() => {
const sessionID = params.id
if (!sessionID) return false
const todos = sync.data.todo[sessionID] ?? []
return todos.some((t) => t.status !== "completed")
})

const tasksTab = createMemo(() => hasIncompleteTasks())

const showTabs = createMemo(
() => layout.review.opened() && (diffs().length > 0 || tabs().all().length > 0 || contextOpen()),
() => layout.review.opened() && (diffs().length > 0 || tabs().all().length > 0 || contextOpen() || tasksTab()),
)

const activeTab = createMemo(() => {
Expand All @@ -658,13 +668,14 @@ export default function Page() {
const first = openedTabs()[0]
if (first) return first
if (contextOpen()) return "context"
if (tasksTab()) return "tasks"
return "review"
})

createEffect(() => {
if (!layout.ready()) return
if (tabs().active()) return
if (diffs().length === 0 && openedTabs().length === 0 && !contextOpen()) return
if (diffs().length === 0 && openedTabs().length === 0 && !contextOpen() && !tasksTab()) return
tabs().setActive(activeTab())
})

Expand Down Expand Up @@ -1013,6 +1024,23 @@ export default function Page() {
</div>
</Tabs.Trigger>
</Show>
<Show when={tasksTab()}>
<Tabs.Trigger value="tasks">
<div class="flex items-center gap-2">
<Icon name="checklist" size="small" />
<div>Tasks</div>
{(() => {
const todos = sync.data.todo[params.id ?? ""] ?? []
const completed = todos.filter((t) => t.status === "completed").length
return (
<div class="text-12-medium text-text-strong h-4 px-2 flex flex-col items-center justify-center rounded-full bg-surface-base">
{completed}/{todos.length}
</div>
)
})()}
</div>
</Tabs.Trigger>
</Show>
<SortableProvider ids={openedTabs()}>
<For each={openedTabs()}>{(tab) => <SortableTab tab={tab} onTabClose={tabs().close} />}</For>
</SortableProvider>
Expand Down Expand Up @@ -1061,6 +1089,11 @@ export default function Page() {
</div>
</Tabs.Content>
</Show>
<Show when={tasksTab()}>
<Tabs.Content value="tasks" class="flex flex-col h-full overflow-hidden contain-strict">
<SessionTaskPanel />
</Tabs.Content>
</Show>
<For each={openedTabs()}>
{(tab) => {
let scroll: HTMLDivElement | undefined
Expand Down