Compare commits

...

306 Commits

Author SHA1 Message Date
opencode-agent[bot]
354fe7f884 chore: update nix node_modules hashes 2026-03-05 05:35:30 +00:00
opencode-agent[bot]
380cbea0fe Apply PR #15951: fix(tui): use WinRT clipboard API on Windows 2026-03-05 05:32:43 +00:00
opencode-agent[bot]
77c5474d37 Apply PR #15863: ANIMATION RETRIBUTION II: PROLAPSED OUBLIETTE 2026-03-05 05:32:42 +00:00
opencode-agent[bot]
adb4a46f11 Apply PR #15698: tweak(ui): add sidebar fade mask under new buttons 2026-03-05 05:31:47 +00:00
opencode-agent[bot]
ba1fb54832 Apply PR #15697: tweak(ui): make questions popup collapsible 2026-03-05 05:31:47 +00:00
opencode-agent[bot]
4bf23b9be4 Apply PR #15487: core: make account login upgrades safe while adding multi-account workspace auth 2026-03-05 05:31:47 +00:00
opencode-agent[bot]
9d113e0093 Apply PR #15322: desktop: new-session deeplink 2026-03-05 05:31:25 +00:00
opencode-agent[bot]
83638fc507 Apply PR #15269: feat(ui): make edited file references clickable in desktop session UI 2026-03-05 05:31:24 +00:00
opencode-agent[bot]
d61f905e6d Apply PR #15266: feat(app): changelog with PR links 2026-03-05 05:31:24 +00:00
opencode-agent[bot]
f4db8132e1 Apply PR #15250: feat(app): view archived sessions & unarchive 2026-03-05 05:31:06 +00:00
opencode-agent[bot]
d750f80576 Apply PR #15013: refactor: replace Bun.sleep with node timers 2026-03-05 05:31:06 +00:00
opencode-agent[bot]
13b23b8403 Apply PR #15012: refactor(opencode): replace Bun.which with npm which 2026-03-05 05:31:06 +00:00
opencode-agent[bot]
0c398d63ae Apply PR #14677: feat: add experimental hashline edit mode with dual-schema support 2026-03-05 05:28:33 +00:00
opencode-agent[bot]
d15eac5037 Apply PR #14471: [DO NOT MERGE]: beta badge for desktop app 2026-03-05 05:28:33 +00:00
opencode-agent[bot]
4ae9a5efeb Apply PR #14307: fix: use parentID matching instead of ID ordering for prompt loop exit and message rendering 2026-03-05 05:28:32 +00:00
opencode-agent[bot]
62ccc2332b Apply PR #12633: feat(tui): add auto-accept mode for permission requests 2026-03-05 05:28:32 +00:00
opencode-agent[bot]
f2bc2b59a8 Apply PR #12022: feat: update tui model dialog to utilize model family to reduce noise in list 2026-03-05 05:28:32 +00:00
Frank
85ff05670a zen: update go page 2026-03-04 22:23:22 -05:00
Kit Langton
f03910cc4b feat(ui): add ComposerIsland dynamic island composer prototype
Storybook-only component that morphs between input and question modes
with spring animations, todo tray, and crossfade transitions.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 21:48:07 -05:00
Kit Langton
78cd0c9d7a fix(ui): fix AnimatedNumber clipping direction and shrinking gap
Pack digits to the right (flex-start in row-reverse) so higher-place
digits clip in/out from the left during width transitions. Keep exiting
digits in the DOM for the duration of the width transition to prevent
blank gaps when digit count shrinks.
2026-03-04 21:48:07 -05:00
Kit Langton
e67bedb0b2 fix(ui): adjust tool collapsible trigger height and sticky offset
- Set tool-collapsible trigger height to 37px
- Remove padding-top from tool content inner
- Update sticky accordion offset to match new height
2026-03-04 21:48:06 -05:00
Kit Langton
37d3ad0475 fix(ui): stabilize rolling results identity and improve animations
- Fix For teardown by removing Show wrapper that destroyed For on store updates
- Cache ctxParts references for stable For identity across store recomputation
- Guard against transient empty array flash during grouped memo recompute
- Remove Show wrapper around ContextToolRollingResults to prevent remounting
- Add detail wipe animation (blur + opacity + mask sweep) using signal ref
- Make context tool labels reactive for delayed argument arrival
- Remove row dim animation (too slow to be noticeable)
- Use GROW_SPRING (0.5s) for height animation to match track shift speed
- Simplify height calculation: remove fade extension and negative margin hack
- Shrink bottom mask fade zone to reduce overlap with last visible row
- Simulate delayed argument arrival in timeline simulator stories
2026-03-04 21:48:06 -05:00
Kit Langton
7de99b6a94 feat(ui): context tool group with rolling results, expandable list, and virtualization
- Add RollingResults component with spring-animated height/scroll, row dimming,
  and virtualization backstop (tail-slice + padding-top for skipped items)
- Extract context tool group into ContextToolGroupHeader (trigger only),
  ContextToolExpandedList (spring-animated expand/collapse with scroll-aware
  edge masks), and ContextToolRollingResults (useSpring opacity/blur fade)
- Render rolling results and expanded list as siblings outside PartGrow to
  avoid double height animation from GrowBox
- Fix ToolCall row variant reactivity: use Show instead of plain if so
  showArrow updates when pending transitions to false
- Guard context group toggle during pending state (no click, no pointer cursor)
- Match expanded list row styles to rolling results (medium-weight action, dimmed detail)
- Add realistic file paths/patterns to session timeline simulator stories
- Snap header opacity for existing sessions instead of slow fade
2026-03-04 21:48:06 -05:00
Kit Langton
6e123c5f2a refactor(app): consolidate isEditableTarget into utils/dom
Merge the two implementations from session.tsx and command.tsx into a
single shared function that covers tagName checks, contentEditable,
and ancestor traversal.
2026-03-04 21:48:05 -05:00
Kit Langton
2b5a95bb88 refactor(app): deduplicate errorMessage into shared import from layout/helpers 2026-03-04 21:48:05 -05:00
Kit Langton
11522769fb refactor(util): consolidate same array comparator into @opencode-ai/util
Move the `same` utility from app/utils/same.ts into @opencode-ai/util/array
so both @opencode-ai/ui and @opencode-ai/app can share a single definition.
2026-03-04 21:48:05 -05:00
Kit Langton
1fca726266 fix(ui): smooth streaming text edge fade lifecycle 2026-03-04 21:48:04 -05:00
Kit Langton
3a654ccd56 fix(session): preserve manual expand anchor near bottom
Keep user-triggered collapsible expands anchored to the clicked trigger so timeline content does not jump while streaming lock is active. Add hold-state coordination and wheel handoff to avoid bottom-lock and manual correction fighting each other.
2026-03-04 21:48:04 -05:00
Kit Langton
6784f917c7 refactor(ui): remove deprecated message wrapper and unused tool title styles 2026-03-04 21:48:03 -05:00
Kit Langton
a2fb4ca322 fix(ui): speed up text shimmer and remove idle glow pulse 2026-03-04 21:48:03 -05:00
Kit Langton
6082393db3 fix(ui): keep Agent title fixed, animate type label in separately
Title no longer mutates from "Agent" to "explore Agent". Instead,
the title stays "Agent" and the subagent type (e.g. "Explore")
wipe-animates in as a subtitle once known, followed by the
description. Updated all i18n files to remove {{type}} from the
agent label.
2026-03-04 21:48:03 -05:00
Kit Langton
b7be8787fb fix(ui): smooth session turn handoff to hover meta 2026-03-04 21:48:02 -05:00
Kit Langton
92534bbabd refactor(ui): remove assistant text part copy/meta UI and use NBSPs in assistant meta separators 2026-03-04 21:48:01 -05:00
Kit Langton
302f4bd863 fix(ui): fix inconsistent dot separator spacing in user message meta
Render user meta (agent, model, timestamp) as a single text node
instead of separate spans, eliminating JSX whitespace that caused
uneven spacing around the middot separators.
2026-03-04 21:48:01 -05:00
Kit Langton
3032fe445d fix(ui): keep automatic context collapse on grow timing
Route GrowBox mount and toggle animations through separate spring configs so COLLAPSIBLE_SPRING only applies after user-driven toggles. Also wrap loaded-file rows in GrowBox to animate height expansion instead of popping in.
2026-03-04 21:48:01 -05:00
Kit Langton
1abb7efc28 fix(app): prevent session header flash on first message 2026-03-04 21:48:00 -05:00
Kit Langton
7fe615247b fix(ui): cache reduced-motion query and clean up deferRender timers
- Add module-level prefersReducedMotion signal in message-part.tsx,
  replacing per-mount window.matchMedia calls in useToolFade
- Cancel pending rAF/setTimeout in session.tsx deferRender when session
  key changes rapidly, and clean up on unmount
2026-03-04 21:48:00 -05:00
Kit Langton
6455ccb609 refactor(app): extract useElementHeight hook and clean up todo dock
- Extract shared useElementHeight hook into @opencode-ai/ui/hooks,
  replacing duplicated ResizeObserver patterns in session-composer-region
  and session-todo-dock
- Replace 13 animation tuning props with module-level constants (they
  were always passed as undefined from session.tsx)
- Export COLLAPSED_HEIGHT from session-todo-dock to share with
  session-composer-region
- Strip unnecessary clamps on critically damped springs
- Remove ~280 lines of dead slider UI from todo-panel-motion story
2026-03-04 21:47:59 -05:00
Kit Langton
868fc1829a refactor(app): deduplicate spring fade styles and remove redundant code
Extract springFade helper in prompt-input to replace 7 duplicated inline
style blocks. Remove redundant turn memo in session-todo-dock (identical
to value). Remove redundant first queue.refresh() call in global-sync.
2026-03-04 21:47:40 -05:00
Kit Langton
4c0f6cbc38 refactor(ui): deduplicate animation utils and shared helpers
Extract commonPrefix() and list() into text-utils.ts, centralize
WIPE_MASK/FAST_SPRING/clearMaskStyles/clearFadeStyles in motion.tsx,
deduplicate fadeInTitle enter block, fix GrowBox null guards, and
cancel leaked rAF in ToolCallPanel onMount.
2026-03-04 21:47:30 -05:00
Kit Langton
c2796fa803 perf(ui): use overflow: clip instead of hidden, drop stale will-change
overflow: clip avoids creating a scroll container and block formatting
context, eliminating compositor clipping bugs and stacking context
overhead. Also removes unused transform from will-change on the
thinking element and drops text-overflow: ellipsis in favor of clip.
2026-03-04 21:47:29 -05:00
Kit Langton
8efadf9858 chore(ui): slow down tool-count animation durations
Width/transform transitions bumped to 800ms, opacity/filter to 400ms
across count summary, suffix, and status title components. Odometer
digit roll duration increased to 800ms.
2026-03-04 21:47:29 -05:00
Kit Langton
2dcb54c5f2 refactor(ui): replace manual thinking animation with GrowBox
Replace ~90 lines of manual animate()/springValue thinking indicator
code with a GrowBox wrapper. Both the thinking collapse and copy row
growth now use the same springValue mechanism with identical
HEIGHT_SPRING config, eliminating the vertical bounce when the session
completes (both springs produce the same normalized progress curve).

Also fix animation breaking when navigating away from a streaming
session and back: the active turn now always receives animate=true
via `animate={isNew || active()}` so the live() gate can activate
regardless of backfill staging state.
2026-03-04 21:47:29 -05:00
Kit Langton
c0fd605d73 refactor(ui): rename animated → springContent in ToolCallPanel
Clarifies the confusing animate/animated prop pair. `animate` controls
whether animations are globally enabled (false during backfill).
`springContent` declares that this tool panel wants spring-animated
height transitions for its collapsible content.
2026-03-04 21:47:29 -05:00
Kit Langton
986a6b19e0 chore(ui): remove trivial indirection and dead code
- Hoist empty array constants and idle sentinel to module scope
- Merge showAssistantCopyPartID into assistantCopyPartID (return null)
- Remove thinking() pass-through alias for showThinking()
- Remove entry() memo wrapping live() signal
- Inline gap() constant ("0px") at all callsites
- Remove unused rootRef in ScrollView
2026-03-04 21:47:28 -05:00
Kit Langton
2c39a7fcb3 refactor(ui): remove self-detection from SessionTurn
SessionTurn no longer scans the message list to figure out if it's
active or queued — it just reads the props (defaulting to false).
Deletes pending(), pendingUser(), latestUserID(), and all the typeof
guards that switched between explicit and self-detect modes.
2026-03-04 21:47:28 -05:00
Kit Langton
9939e735bb fix(ui): restore queued message feature and clean up dead code
Restore the queued message indicator that was accidentally dropped during
the animation refactor, now with GrowBox spring animation. Fix three bugs:
queued label only shows on actually-queued messages (not all after interrupt),
new user messages animate height in via staging.ready() gate, and thinking
indicator stays visible on the active turn when a queued message follows.

Also remove dead code: unused imports, console.debug calls, orphaned CSS,
unreachable guards, and unexported internal constants.
2026-03-04 21:47:28 -05:00
Kit Langton
0461efa8ab fix(session): stabilize e2e teardown races 2026-03-04 21:47:27 -05:00
Kit Langton
bffda3716e fix(session): stop gracefully after deleted session writes 2026-03-04 21:47:27 -05:00
Kit Langton
bb8b850b5f fix(ui): animate immediate tool subtitles on first reveal 2026-03-04 21:47:27 -05:00
Kit Langton
4ef8ae9ddb chore(ui): clean up tool-call icon experiment 2026-03-04 21:47:26 -05:00
Kit Langton
abe4a2548f fix(ui): simplify metaTail to return stamp directly
No longer wraps a single value in an array with filter+join.
2026-03-04 21:47:26 -05:00
Kit Langton
82a29ba9b9 fix(ui): add top padding to text parts after tool calls
Text/reasoning parts get 8px top padding inside the GrowBox body
so it animates with height. Tool/context parts stay at 0.
2026-03-04 21:47:26 -05:00
Kit Langton
7ebf1c3707 fix(ui): keep interrupted copy row left-aligned 2026-03-04 21:47:26 -05:00
Kit Langton
24bcc96511 fix(ui): unify turn row heights and remove inter-part gaps
Standardize all turn rows (tool calls, thinking, diff summary) to 36px
height with zero gaps between them. Previously rows were 32px with
varying gaps (2px/6px/8px/12px) causing inconsistent spacing and a
margin-top pop when the thinking indicator's gap changed reactively.

- Collapsible trigger: 32px -> 36px
- Thinking box: min-height 32px -> height 36px, gap removed (was 12px)
- Diff summary trigger: min-height 24px -> height 36px, gap removed
- Part gaps: removed all inter-part padding-top (was 0/2/6px)
- User message GrowBox: removed 8px gap
- Thread working prop to text parts for copy button visibility
- Wrap copy button in animated GrowBox with open/close transitions
- Simplify thinking show/hide by removing delay timer and using
  entry gate to prevent animation before live signal
- Batch optimistic message updates to prevent animate race condition
2026-03-04 21:47:25 -05:00
Kit Langton
7e5477c7c4 fix(ui): close tool-output css block for app build 2026-03-04 21:47:25 -05:00
Kit Langton
a0296094c5 fix(ui): preserve SolidJS reactivity and clean up minor issues
Use splitProps instead of JS destructuring in ToolCallRoot to preserve
reactivity on forwarded props. Extract fade helpers in GrowBox to
deduplicate opacity/filter/blur patterns across mount and toggle paths
(also fixes mount fade-in cleanup not checking open() state). Simplify
redundant getDirectory ternary guards and contextToolSummary iteration.
2026-03-04 21:47:25 -05:00
Kit Langton
7add2e67c3 chore(ui): add skill label translations 2026-03-04 21:47:24 -05:00
Kit Langton
a16d77b08d fix(app/ui/opencode): align tool detail reveal timing and turn animation gating 2026-03-04 21:47:24 -05:00
Kit Langton
83de487dcd fix(app): restore staging render path and nested scroll boundary checks 2026-03-04 21:47:24 -05:00
Kit Langton
4e05d3487c fix(app/ui): resolve rebase regressions 2026-03-04 21:47:23 -05:00
Kit Langton
9cba8f9349 fix(ui): inline turn diff summary under assistant text 2026-03-04 21:47:23 -05:00
Kit Langton
45eb4be7f2 refactor(ui): standardize tool call variants 2026-03-04 21:47:23 -05:00
Kit Langton
8851c619b4 fix(ui): speed up nested collapsible panels 2026-03-04 21:47:23 -05:00
Kit Langton
418ecc7073 fix(ui): align defer behavior for animated tools 2026-03-04 21:47:22 -05:00
Kit Langton
55762b2bbb fix(ui): restore in-flow user copy row spacing 2026-03-04 21:47:22 -05:00
Kit Langton
0ce963bfb3 fix(ui): keep apply_patch tool collapsed by default 2026-03-04 21:47:22 -05:00
Kit Langton
6f8f0d1830 fix(ui): fix backfill scroll preservation for column-reverse
Disable browser scroll anchoring (overflow-anchor: none) which
interferes with column-reverse, and restore scrollTop synchronously
before paint instead of in rAF with delta math.
2026-03-04 21:47:21 -05:00
Kit Langton
969f434769 fix(ui): restore patch collapse behavior and add user message entry spacing 2026-03-04 21:47:21 -05:00
Kit Langton
49f55ae426 fix(ui): fix user message animation and smooth BasicTool toggle
- Pass props.animate (not live()) to Message so user message GrowBox
  mounts with animate=true and properly runs height + fade animation
- Remove Math.ceil from BasicTool height spring to prevent 1px jitter
- Remove rAF debounce from BasicTool ResizeObserver for same-frame updates
- Use live() for AssistantParts to defer animation until streaming starts
2026-03-04 21:47:21 -05:00
Kit Langton
28538bc65d fix(ui): use column-reverse for jitter-free bottom-anchored scrolling
Switch the scroll viewport to flex-direction: column-reverse so the
browser natively anchors to the bottom (scrollTop=0 = bottom). This
eliminates the 1-frame jitter between content height changes and scroll
position updates. Update all scrollTop math across auto-scroll, scroll
spy, gesture detection, hash scroll, and custom thumb to account for
the inverted coordinate system.
2026-03-04 21:47:21 -05:00
Kit Langton
1b9ca3da27 fix(ui): eliminate auto-scroll jitter and add compositor hints
Remove rAF debounce from ResizeObserver callback in createAutoScroll
so scrollToBottom fires in the same frame as layout changes (no 1-frame
lag). Add GPU layer promotion (translateZ) on GrowBox and layout
containment on assistant content to reduce cross-element repaint.
2026-03-04 21:47:20 -05:00
Kit Langton
c96a3b15c5 fix(ui): remove legacy shell submessage width style 2026-03-04 21:47:20 -05:00
Kit Langton
21b6a5f5fd fix(ui): reduce height animation jitter in GrowBox and thinking box
- Remove Math.ceil rounding from spring animation frames to eliminate
  1px oscillation during height transitions
- Add will-change:height and contain:layout style during active springs
  for better compositor performance
- Replace blanket ResizeObserver gate with spring target deduplication
  so genuine content changes can re-target mid-flight without feedback loops
- Apply same Math.ceil removal and will-change/contain hints to the
  thinking box show/hide animations in session-turn
2026-03-04 21:47:19 -05:00
Kit Langton
474e2e5165 fix(ui): smooth timeline motion and auto-scroll during tool updates 2026-03-04 21:47:19 -05:00
Kit Langton
eadd42bfe1 feat(ui): add gradient wipe reveal for tool metadata 2026-03-04 21:47:19 -05:00
Kit Langton
b939299a0f wip: checkpoint tool-call motion and timeline animation fixes 2026-03-04 21:47:18 -05:00
Kit Langton
bc56419124 fix(ui): keep TextShimmer mounted for smooth transitions, move shell submessage fade to JS
- Fix 6 instances where TextShimmer was destroyed/recreated via <Show>
  swap instead of toggling active prop (bash, webfetch, edit, write,
  context tools, basic-tool fallback)
- Move shell submessage opacity/blur from CSS transitions to animate()
  so they respect the animate prop and don't fire on page load
- Remove data-visible attribute pattern, all animation now driven by
  Motion's animate() when animate=true
2026-03-04 21:46:52 -05:00
Kit Langton
55ab1f094c feat(ui): spring width animation for shell submessage, replace TextOdometer with TextReveal
- Shell submessage now uses Motion's animate() with width: "auto" for
  spring-driven width reveal instead of CSS grid 0fr→1fr transition
- Skip animation on page load (sawPending flag), only animate live tool calls
- Fix baseline alignment with overflow: clip instead of overflow: hidden
- Replace TextOdometer with TextReveal in production (session-turn, todo-dock)
- Remove TextOdometer component, CSS, and stories
- Add TextReveal to thinking-heading story
- Update shell submessage story with visualDuration/bounce sliders
2026-03-04 21:46:52 -05:00
Kit Langton
14f88ae889 feat(ui): spring animations for composer mode toggle and tray transitions
Add spring-based animations to the prompt input composer:
- Mode toggle (shell/conversation) indicator uses spring cubic-bezier
- Submit and plus buttons animate with individual scale, opacity, and blur
- Tray items (agent, model, variant selectors) crossfade with spring
- Shell label animates in/out opposite to normal mode controls
- Add TextStrikethrough component for todo item completion
- Add truncate support to TextReveal
- Wire up count mask/height/width props through composer region
2026-03-04 21:46:51 -05:00
Kit Langton
9d53b3a221 wip: checkpoint todo panel + odometer motion work 2026-03-04 21:46:46 -05:00
Kit Langton
0ad0d84402 chore(storybook): simplify animated count story locales 2026-03-04 21:46:30 -05:00
Kit Langton
33b6ba68fc refactor(ui): simplify text shimmer API and story controls 2026-03-04 21:46:30 -05:00
Kit Langton
90345c57e1 tweak(ui): shimmering titles and animated counts 2026-03-04 21:46:29 -05:00
Anton Volkov
324230806e chore: update turborepo (#16061) 2026-03-05 09:49:11 +10:00
Luke Parker
7f7e622426 dont let dax touch the ui (#16060) 2026-03-04 23:31:48 +00:00
Frank
27447bab26 wip: zen 2026-03-04 18:29:38 -05:00
James Long
45ac20b8aa fix(core): handle SIGHUP and kill process (#16057) 2026-03-04 23:12:05 +00:00
Frank
218330aec1 Merge branch 'go-page' into dev 2026-03-04 18:00:11 -05:00
David Hill
67fa7903c3 tui: prevent Go pricing graph from overflowing on medium screens by constraining width and moving axis labels outside SVG for sharper rendering 2026-03-04 22:31:23 +00:00
David Hill
cd3a09c6a7 tui: clearer graph labels and responsive layout for usage visualization
Improve readability of the usage graph y-axis label by spelling out
'Requests per 5 hour' instead of the abbreviated 'Requests/5h'. Fix
layout on smaller screens by removing negative margin that was
causing the graph to overflow its container.
2026-03-04 22:19:28 +00:00
David Hill
f8685a4d53 tui: clarify free tier includes Big Pickle and promotional requests on Go pricing page 2026-03-04 22:16:30 +00:00
David Hill
6cbb1ef1c2 wip: Make bar colors in limit graph customizable via CSS variables for consistent theming across the go route visualization 2026-03-04 22:10:17 +00:00
David Hill
0b825ca383 docs: redesign Go pricing graph with horizontal bars and inline request labels
Improve visual clarity of request limits on the Go pricing page by replacing
dot-based chart with animated horizontal bars that directly show model names
and exact request counts. Add proper OpenGraph and Twitter Card meta tags for
better social sharing discovery.
2026-03-04 22:07:49 +00:00
opencode-agent[bot]
22a4c5a77e docs(i18n): sync locale docs from english changes 2026-03-04 18:39:59 +00:00
Scott Tolinski
29dbfc25e5 docs: Add opencode-sentry-monitor to ecosystem documentation (#16037) 2026-03-04 13:34:17 -05:00
David Hill
40fc406424 ci: make tsgo available for pre-push typechecks 2026-03-04 16:48:29 +00:00
David Hill
6f23271741 chore(ui): remove quotes 2026-03-04 16:45:34 +00:00
David Hill
b7198c28c8 tweak(ui): darker text 2026-03-04 16:43:43 +00:00
David Hill
de6a6af5ab tweak(ui): remove section 2026-03-04 16:42:30 +00:00
David Hill
0f1f55a24c tui: show Go request limits per 5-hour session 2026-03-04 16:35:03 +00:00
David Hill
744c38cc7c tui: clarify which models are available in Go subscription
Adds list of included AI models (GLM-5, Kimi K2.5, and MiniMax M2.5) to the Go page so users know exactly what model access their subscription provides
2026-03-04 16:19:06 +00:00
Frank
e9de2505f6 Merge branch 'dev' into go-page 2026-03-04 11:13:14 -05:00
David Hill
22fcde926f tui: reduce excessive spacing in go route layout to improve visual balance 2026-03-04 16:09:51 +00:00
David Hill
b42a63b882 docs: make Go hero CTA translatable with pricing emphasis 2026-03-04 16:02:37 +00:00
David Hill
ca5a7378de docs: localize Go graph and testimonial copy 2026-03-04 15:58:58 +00:00
David Hill
c6187ee40f docs: de-link Go testimonials and swap Zen→Go 2026-03-04 15:49:45 +00:00
David Hill
d94c516402 docs: update Go privacy copy for global hosting 2026-03-04 15:47:53 +00:00
David Hill
61795d794e docs: clarify Go models in FAQ answer 2026-03-04 15:24:51 +00:00
David Hill
7c215c0d02 docs: replace Go landing page video with interactive limits graph
Users can now see a clear visual comparison of request limits between Free tier and Go tier across all available models, making it easier to understand the value of a Go subscription at a glance.
2026-03-04 15:21:07 +00:00
opencode
715b844c2a release: v1.2.17 2026-03-04 14:58:04 +00:00
Brendan Allan
1b0d65f80e ci: remove electron beta requirement 2026-03-04 22:38:23 +08:00
Brendan Allan
faf501200e ci: only publish electron on beta 2026-03-04 22:21:56 +08:00
Dax Raad
e3267413c2 remove build from typecheck 2026-03-04 09:20:24 -05:00
Sebastian
18cad10647 show scrollbar by default (#15282) 2026-03-04 15:10:27 +01:00
Adam
a69742ccb2 fix(app): remove blur from todos 2026-03-04 07:43:28 -06:00
Adam
64b21135f9 fix(app): delay dock animation on session load 2026-03-04 07:39:34 -06:00
Adam
e482405cdc fix(app): remove diff lines from sessions in sidebar 2026-03-04 07:36:10 -06:00
David Hill
ad56338108 chore(console): update copy 2026-03-04 13:24:08 +00:00
Adam
2ccf21de99 fix(app): loading session should be scrolled to the bottom 2026-03-04 07:18:09 -06:00
David Hill
dd4ad5f2c5 chore(console): edit copy 2026-03-04 13:08:21 +00:00
David Hill
eb71856733 docs: send Go landing page links to Go docs 2026-03-04 13:05:19 +00:00
Adam
d7569a5625 fix(app): terminal tab close 2026-03-04 07:04:03 -06:00
David Hill
0a2aa8688d chore(console): switch /go page to go.* i18n keys 2026-03-04 12:57:49 +00:00
David Hill
e44cdaf887 chore(console): use Go ornate logo on /go 2026-03-04 12:15:40 +00:00
David Hill
5709561917 chore(console): refine /go hero and pricing copy 2026-03-04 12:01:15 +00:00
David Hill
9909f94048 chore(console): hide Go nav item on /go 2026-03-04 11:46:30 +00:00
David Hill
e8f67ddbcc chore(console): update /go hero body 2026-03-04 11:34:44 +00:00
opencode-agent[bot]
0541d756a6 docs(i18n): sync locale docs from english changes 2026-03-04 11:27:41 +00:00
David Hill
a2d3d62db3 chore(console): move login to end on zen/go 2026-03-04 11:26:50 +00:00
shivam kr chaudhary
850fd9419e fix(docs): update dead opencode-daytona ecosystem link (#15979)
Co-authored-by: Shoubhit Dash <shoubhit2005@gmail.com>
2026-03-04 11:18:28 +00:00
Brendan Allan
db3eddc51f ui: rely on task part href instead of onClick handler (#15978) 2026-03-04 16:41:20 +08:00
opencode-agent[bot]
5dcf3301e0 chore: update nix node_modules hashes 2026-03-04 07:35:19 +00:00
Brendan Allan
b82f1d6145 Merge branch 'dev' into brendan/new-session-deeplink 2026-03-04 15:31:56 +08:00
Brendan Allan
5cf235fa6c desktop: add electron version (#15663) 2026-03-04 15:12:34 +08:00
Frank
e4f0825c56 zen: fix aws bedrock header 2026-03-03 23:45:43 -05:00
LukeParkerDev
695a26a168 fix(tui): use WinRT clipboard API on Windows 2026-03-04 13:26:03 +10:00
Dax
3ebebe0a96 fix(process): prevent orphaned opencode subprocesses on shutdown (#15924) 2026-03-03 22:14:28 -05:00
opencode-agent[bot]
2a0be8316b chore: generate 2026-03-04 02:36:18 +00:00
James Long
7f37acdaaa feat(core): rework workspace integration and adaptor interface (#15895) 2026-03-03 21:35:38 -05:00
Dax
e79d41c70e docs(bash): clarify output capture guidance (#15928) 2026-03-03 21:10:26 -05:00
Andrea Alberti
109ea1709b fix: run --attach agent validation (#11812)
Co-authored-by: LukeParkerDev <10430890+Hona@users.noreply.github.com>
2026-03-03 22:30:16 +00:00
Copilot
9a42927268 revert: undo turbo typecheck dependency change from #14828 (#15902)
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: Hona <10430890+Hona@users.noreply.github.com>
2026-03-04 07:35:24 +10:00
opencode
e66d829d18 release: v1.2.16 2026-03-03 21:08:35 +00:00
Dax Raad
c4ffd93caa tui: replace curved arrow with straight arrow for better terminal compatibility 2026-03-03 15:25:11 -05:00
Dax Raad
c78e7e1a28 tui: show pending toolcall count instead of generic 'Running...' message 2026-03-03 15:08:11 -05:00
Dax
e3a787a7a3 tui: use arrow indicator for active tool execution (#15887) 2026-03-03 14:46:07 -05:00
Alex Yaroshuk
92aab78442 Merge branch 'dev' into feat/changelog 2026-03-04 03:29:28 +08:00
Alex Yaroshuk
1bbd3a71fb fix: wrap DialogChangelog with DataProvider 2026-03-04 03:26:57 +08:00
David Hill
12f4315d9d chore(console): trim /go model logos 2026-03-03 19:04:48 +00:00
David Hill
d80334b2bc chore(console): update /go hero copy 2026-03-03 19:01:24 +00:00
David Hill
c2f5abe759 chore(console): move Enterprise after Go 2026-03-03 18:51:53 +00:00
Matt Silverlock
74ebb4147f fix(auth): normalize trailing slashes in auth login URLs (#15874) 2026-03-03 13:35:49 -05:00
Frank
1663c11f40 wip: zen 2026-03-03 13:30:34 -05:00
David Hill
b1c166edf4 chore(console): add Go to nav 2026-03-03 18:30:04 +00:00
David Hill
3c8ce4ab90 feat(console): add /go landing page 2026-03-03 18:29:42 +00:00
Adam
502dbb65fc fix(app): defer diff rendering 2026-03-03 11:14:03 -06:00
Alex Yaroshuk
bb2e9fffb1 Merge branch 'dev' into feat/changelog 2026-03-04 01:09:06 +08:00
Adam
9d427c1ef8 fix(app): defer diff rendering 2026-03-03 11:00:06 -06:00
Adam
70c6fcfbbf chore: cleanup 2026-03-03 10:25:49 -06:00
opencode-agent[bot]
6f90c3d73a chore: update nix node_modules hashes 2026-03-03 15:52:03 +00:00
Sebastian
3310c25dd1 Upgrade opentui to v0.1.86 and activate markdown renderable by default (#14974) 2026-03-03 15:42:27 +00:00
opencode-agent[bot]
b751bd0373 docs(i18n): sync locale docs from english changes 2026-03-03 15:03:53 +00:00
Frank
c2091acd8c wip: zen 2026-03-03 09:44:34 -05:00
Adam
fd4d3094bf fix(app): timeline jank 2026-03-03 08:28:56 -06:00
Adam
10c325810b fix(app): tighten up header elements 2026-03-03 07:19:24 -06:00
Adam
fa45422bf9 chore: cleanup 2026-03-03 07:10:52 -06:00
Adam
da82d4035a chore: tr glossary 2026-03-03 07:10:52 -06:00
opencode-agent[bot]
70b6a05235 chore: generate 2026-03-03 12:28:35 +00:00
İbrahim Hakkı Ergin
cbf0570489 fix: update Turkish translations (#15835) 2026-03-03 06:27:54 -06:00
Jack
356b5d4601 fix(app): stabilize project close navigation (#15817)
Co-authored-by: Adam <2363879+adamdotdevin@users.noreply.github.com>
2026-03-03 06:25:55 -06:00
Adam
7305fc044d chore: cleanup 2026-03-03 06:20:48 -06:00
Adam
1e2da60162 chore: fix test 2026-03-03 05:53:03 -06:00
Adam
e4af1bb422 fix(app): timeline jank 2026-03-03 05:35:15 -06:00
Adam
5e8742f431 fix(app): timeline jank 2026-03-03 05:35:15 -06:00
Jérôme Benoit
18850c4f91 fix(opencode): disable session navigation commands when no parent session (#15762)
Co-authored-by: Test User <test@test.com>
Co-authored-by: Shoubhit Dash <shoubhit2005@gmail.com>
2026-03-03 16:00:25 +05:30
Caleb Norton
48412f75ac chore: nix flake update for bun 1.3.10 (#15648)
Co-authored-by: Jérôme Benoit <jerome.benoit@piment-noir.org>
2026-03-03 12:16:43 +05:00
Frank
6deb27e852 zen: docs 2026-03-03 01:27:55 -05:00
Frank
b985ea344b wip: zen 2026-03-03 01:00:50 -05:00
Frank
1233ebcce1 wip: zen 2026-03-03 00:52:12 -05:00
opencode-agent[bot]
881ca86432 chore: generate 2026-03-03 05:26:22 +00:00
Frank
6aa4928e9e wip: zen 2026-03-03 00:25:03 -05:00
opencode-agent[bot]
9f150b0776 chore: generate 2026-03-03 04:41:52 +00:00
Shoubhit Dash
7e3e85ba59 fix(opencode): avoid gemini combiner schema sibling injection (#15318) 2026-03-03 10:11:05 +05:30
opencode-agent[bot]
e41b53504f chore: generate 2026-03-03 03:53:50 +00:00
Ryan Skidmore
96d6fb78da fix(provider): forward metadata options to cloudflare-ai-gateway provider (#15619) 2026-03-02 22:53:03 -05:00
Ryan Skidmore
fd6f7133c5 fix(opencode): clone part data in Bus event to preserve token values (#15780) 2026-03-02 22:52:43 -05:00
opencode-agent[bot]
98c75be7e1 chore: update nix node_modules hashes 2026-03-02 22:38:45 +00:00
opencode-agent[bot]
b5dc6e670a chore: generate 2026-03-02 22:25:39 +00:00
Kit Langton
9d7852b5c3 Animation Smorgasbord (#15637)
Co-authored-by: Adam <2363879+adamdotdevin@users.noreply.github.com>
2026-03-02 16:24:32 -06:00
Adam
78069369e2 fix(app): default auto-respond to false 2026-03-02 13:10:37 -06:00
MakonnenMak
e993acec31 Merge remote-tracking branch 'upstream/dev' into fix/clock-skew-prompt-loop-exit
# Conflicts:
#	packages/ui/src/components/session-turn.tsx
2026-03-02 13:30:01 -05:00
Adam
1cd77b1072 chore: fix docs sync permissions 2026-03-02 11:42:20 -06:00
Adam
8176bafc55 chore(app): solidjs refactoring (#13399) 2026-03-02 10:50:50 -06:00
David Hill
0a3a3216db ui: move session review bottom padding
Remove bottom padding from the scroll wrapper and apply it to the accordion content instead.
2026-03-02 16:19:35 +00:00
David Hill
633a3ba03a ui: avoid session review header clipping
Move the session review header outside the scroll viewport and drop strict containment so shadows can render without being cropped.
2026-03-02 16:05:16 +00:00
David Hill
d60696ded8 ui: tighten scroll thumb and review padding
Keep the scroll thumb visually slim (4px) while preserving a 12px drag target, and remove extra right padding in session review content.
2026-03-02 15:37:41 +00:00
David Hill
4c2aa4ab90 ui: widen scroll thumb hit area
Make the thumb overlay 12px wide while keeping the visible bar 6px centered for easier hover/drag.
2026-03-02 15:26:55 +00:00
David Hill
51e6000194 core: keep review header buttons visible when scroll thumb shows 2026-03-02 14:59:12 +00:00
Filip
bf2cc3aa2f feat(app): show which messages are queued (#15587) 2026-03-02 13:27:34 +05:30
opencode-agent[bot]
4b9e19f72f chore: generate 2026-03-02 07:41:53 +00:00
bentrd
be20f865ac fix: recover from 413 Request Entity Too Large via auto-compaction (#14707)
Co-authored-by: Noam Bressler <noamzbr@gmail.com>
2026-03-02 13:10:55 +05:30
Noam Bressler
7bfbb1fcf8 fix: project ID conflict, and update on same session id (#15596) 2026-03-02 13:09:53 +05:30
Brendan Allan
b1bfecb71d desktop: fix latest.json finalizer 2026-03-02 14:36:35 +08:00
Shoubhit Dash
cf78855165 Revert "fix(i18n): polish turkish translations" (#15656) 2026-03-02 06:03:50 +00:00
Brendan Allan
a692e6fdd4 desktop: use correct download link in finalize-latest-json 2026-03-02 11:21:17 +08:00
Dax Raad
a44f78c34a core: maintain backward compatibility with existing account data by restoring legacy ControlAccountTable alongside new AccountTable structure 2026-03-01 14:21:55 -05:00
Dax Raad
a5d727e7f9 core: enable workspace-aware configuration and account management commands
Switch from boolean active flag to workspace_id tracking so users can select which workspace context to operate in. Login now automatically selects the first available workspace and stores it on the account record.

Logout command now actually removes account records and supports targeting specific accounts by email. Switch command provides an interactive picker to change active workspace. Workspaces command lists all available workspaces across accounts.

Configuration now loads workspace-specific settings from the server when an active workspace is selected, enabling per-workspace customization of opencode behavior.
2026-03-01 14:21:55 -05:00
Dax Raad
7b5b665b4a core: support managing multiple authenticated accounts with individual workspace access
Enable users to authenticate with multiple accounts and switch between
them, accessing workspaces from each account separately.
2026-03-01 14:21:55 -05:00
Dax Raad
b5515dd2f7 core: add device flow authentication commands
Allow users to authenticate via browser-based OAuth device flow
with opencode login command. Includes login, logout, switch account,
and workspaces list commands for managing multiple accounts.
2026-03-01 14:21:55 -05:00
Dax Raad
d16e5b98dc core: rename control module to account for clearer authentication management
Refactor internal authentication system by renaming the control module to account,
making it easier to understand that this handles user account credentials and
tokens. Simplify database schema management by removing the centralized schema
exports and letting each module manage its own tables directly.
2026-03-01 14:21:55 -05:00
Dax Raad
9dbf3a2042 core: rename auth command to providers for clearer credential management
The auth command has been renamed to providers to better reflect its purpose of managing AI provider credentials. This makes it easier for users to discover and use the credential management features when configuring different AI providers.
2026-03-01 14:21:55 -05:00
opencode-agent[bot]
d1938a472d chore: generate 2026-03-01 19:17:56 +00:00
Kit Langton
c0483affa6 perf(session): faster session switching via windowed rendering and staged timeline (#15474) 2026-03-01 13:17:04 -06:00
Frank
ae0f69e1fa doc: add zen deprecated models 2026-03-01 13:21:34 -05:00
Dax
90270c615d feat(tui): improve task tool display with subagent keybind hints and spinner animations (#15607) 2026-03-01 17:46:10 +00:00
Rian van der Merwe
6b7e6bde4d fix(opencode): show human-readable message for HTML error responses (#15407) 2026-03-01 09:24:57 -06:00
Filip
b15fb21191 feat(app): add compact ui (#15578) 2026-03-01 08:41:47 -06:00
Filip
c8866e60ba fix(app): make provider icon resolved id reactive (#15583) 2026-03-01 08:40:04 -06:00
Uğur Murat Altıntas
f5eade1d2b fix(i18n): polish turkish translations (#15491) 2026-03-01 06:48:26 -06:00
Filip
438610aa64 fix(app): show proper usage limit errors (#15496) 2026-03-01 06:48:11 -06:00
ryanwyler
c4c0b23bff fix: kill orphaned MCP child processes and expose OPENCODE_PID on shu… (#15516) 2026-03-01 18:08:17 +05:30
anduimagui
da34dfa80b fix(app): apply file-link line targets in in-app fallback
When external open is unavailable or fails, opening from clickable file refs now preserves optional line numbers by selecting the target line in the review file tab.
2026-03-01 12:07:44 +00:00
anduimagui
5e9dd5dca3 feat(ui): open clicked file refs in desktop default apps
Route markdown and tool file-link clicks to the desktop openPath integration when available, with in-app review fallback if external open fails. Also harden file-ref parsing for wrapped paths and ensure in-app fallback activates the opened tab.
2026-03-01 12:07:44 +00:00
anduimagui
1ac4a1a1fa fix(ui): make tool file links keyboard-accessible
Render clickable tool filenames as native buttons with focus-visible styles so edit/write/apply_patch file links are accessible and keep existing click-to-open behavior.
2026-03-01 12:07:44 +00:00
anduimagui
8405c70993 refactor(ui): extract markdown file reference parser
Move inline file-path parsing into a shared helper and add focused tests, including Windows file URL handling, so clickable markdown file links stay stable as parsing rules evolve.
2026-03-01 12:07:44 +00:00
anduimagui
d7b2a15959 fix(ui): restore singular project path helper name 2026-03-01 12:07:44 +00:00
anduimagui
5fee541d52 feat(ui): make file references clickable in session output
Add clickable file links for inline markdown path references and edit/write/apply_patch file entries so desktop and web users can open changed files directly from the timeline.
2026-03-01 12:07:44 +00:00
opencode-agent[bot]
38704acacd chore: generate 2026-03-01 04:44:37 +00:00
inkdust2021
4d968ebd64 docs(ecosystem): add opencode-vibeguard (#15464) 2026-03-01 10:13:52 +05:30
opencode-agent[bot]
b88e8e0e0b chore: generate 2026-03-01 03:10:46 +00:00
James Long
3ee1653f40 feat(core): add workspace_id to session table (#15410) 2026-02-28 22:09:53 -05:00
opencode-agent[bot]
fcd733e3d6 chore: generate 2026-03-01 01:45:44 +00:00
James Long
cec16dfe95 feat(core): add WorkspaceContext (#15409) 2026-02-28 20:44:54 -05:00
Dax Raad
114eb42444 docs: fix broken config imports in translated documentation
Fixed incorrect relative import paths in Bosnian, French, Italian,
Korean, Norwegian, Portuguese, Turkish, and Chinese docs that were
referencing config.mjs from the wrong directory level. This resolves
build errors when viewing translated documentation pages.
2026-02-28 18:54:18 -05:00
Alex Yaroshuk
fe0f298293 fix i18n 2026-03-01 06:00:24 +08:00
Alex Yaroshuk
29d90056e9 Merge branch 'dev' into feat/changelog 2026-03-01 05:48:41 +08:00
Alex Yaroshuk
276d60e82a fix ar.ts, sync dialog-select-file.tsx to fix typecheck 2026-03-01 05:45:07 +08:00
Alex Yaroshuk
9ea36ccd9d sync time.ts specific code to fix typecheck 2026-03-01 05:29:04 +08:00
Adam
e1e18c7abd chore(docs): i18n sync (#15417) 2026-02-28 15:27:11 -06:00
mridul
971bd30516 fix(app): fallback to synthetic icon for unknown provider IDs (#15295) 2026-02-28 15:13:23 -06:00
Alex Yaroshuk
b9ca79f3b6 refactor: use createResource + Suspense instead of manual signals, remove unused imports 2026-03-01 03:11:15 +08:00
Alex Yaroshuk
323e7a36da (sync) update getRealtiveTime call to use the new language arg 2026-02-28 10:30:46 +08:00
Alex Yaroshuk
9faaa6130d Merge branch 'dev' into feat/unarchive 2026-02-28 09:45:55 +08:00
Alex Yaroshuk
2a2082233d fix(app): display skill name in skill tool call (#15413) 2026-02-27 19:18:14 -06:00
Adam
267d2c82de chore: cleanup 2026-02-27 19:12:19 -06:00
Jay
0b8c1f1f7d docs: Update OpenCode Go subscription and usage details (#15415) 2026-02-27 16:04:53 -08:00
Frank
2eb1d4cb9a doc: go 2026-02-27 18:03:39 -05:00
Frank
d2a8f44c22 doc: opencode go 2026-02-27 17:38:30 -05:00
opencode-agent[bot]
1f1f36aac1 chore: update nix node_modules hashes 2026-02-27 21:59:23 +00:00
Adam
7f851da15e chore(console): i18n sync (#15360) 2026-02-27 15:50:50 -06:00
Adam
a3bdb974b3 chore(app): deps 2026-02-27 15:49:38 -06:00
opencode-agent[bot]
46d678fce9 chore: generate 2026-02-27 21:17:37 +00:00
Alex Yaroshuk
1f2348c1ef fix(app): make bash output selectable (#15378) 2026-02-27 15:16:33 -06:00
shivam kr chaudhary
f347194e31 docs: add missing Bosanski link to Arabic README (#15399) 2026-02-27 15:15:48 -06:00
opencode-agent[bot]
7ff2710ce3 chore: generate 2026-02-27 20:37:28 +00:00
James Long
c12ce2ffff feat(core): basic implementation of remote workspace support (#15120) 2026-02-27 15:36:39 -05:00
David Hill
d20698401b tweak(ui): sidebar fade scroll behind button 2026-02-27 17:37:19 +00:00
Brendan Allan
14acf269aa Merge branch 'dev' into brendan/new-session-deeplink 2026-02-27 21:28:20 +08:00
Shoubhit Dash
ab44597018 Merge branch 'dev' into feat/hashline-edit-experimental-v2 2026-02-27 11:52:55 +05:30
Brendan Allan
1d0d427b5f desktop: new-session deeplink 2026-02-27 12:28:56 +08:00
Shoubhit Dash
aec95c4d10 stabilize hashline routing and anchors 2026-02-27 09:45:06 +05:30
Shoubhit Dash
b2c82cb897 Merge branch 'dev' into feat/hashline-edit-experimental-v2 2026-02-27 08:55:43 +05:30
Alex Yaroshuk
20905212f9 Merge branch 'clean-dev' into feat/changelog 2026-02-27 02:07:31 +08:00
Alex Yaroshuk
76cda30896 use early return instead of 'else' in index.ts 2026-02-27 00:36:50 +08:00
Alex Yaroshuk
4f740306f0 fix layout 2026-02-26 23:47:23 +08:00
Alex Yaroshuk
c7e9851826 wip: unarchive 2026-02-26 03:26:47 +08:00
Dax Raad
bf53e1c24b test(opencode): tolerate Windows PATH extension casing 2026-02-25 00:00:27 -05:00
Dax Raad
50004d1f94 refactor: replace Bun.sleep with node timers 2026-02-24 23:54:49 -05:00
Dax Raad
acd7c5ad55 core: add tests for command resolution to ensure reliable tool discovery across platforms 2026-02-24 23:50:11 -05:00
Dax Raad
cf54b544e3 refactor(opencode): replace Bun.which with npm which 2026-02-24 23:42:25 -05:00
Shoubhit Dash
52b42258fa Merge branch 'dev' into feat/hashline-edit-experimental-v2 2026-02-23 15:16:20 +05:30
Shoubhit Dash
3026a005b6 test: reorder hashline config test for beta merge 2026-02-23 10:05:12 +05:30
Shoubhit Dash
a6f802d7fe fix: align codex prompt with edit routing 2026-02-22 22:39:03 +05:30
Shoubhit Dash
9ef803be82 feat: enable hashline by default 2026-02-22 22:31:33 +05:30
Shoubhit Dash
ce5c827a6e chore: remove local opencode config flags 2026-02-22 19:46:59 +05:30
Shoubhit Dash
56decd79db feat: add experimental hashline edit mode 2026-02-22 19:40:34 +05:30
MakonnenMak
fc258ea74f fix: remove as any type cast in processor exit logic 2026-02-20 13:20:10 -05:00
Makonnen
abd9e195ac fix: use parentID matching instead of ID ordering for prompt loop exit and message rendering
When the client clock is ahead of the server, user message IDs (generated
client-side) sort after assistant message IDs (generated server-side).
This broke the prompt loop exit check and the UI message pairing logic.

- Extract shouldExitLoop() into a pure function that uses parentID matching
  instead of relying on ID ordering
- Extract findAssistantMessages() with forward+backward scan to handle
  messages sorted out of expected order due to clock skew
- Remove debug console.log statements added during investigation
- Add tests for both extracted functions

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-20 13:20:10 -05:00
Adam
9d78b69cd3 wip(app): beta badge 2026-02-20 10:59:59 -06:00
Dax
e31f00ad22 Merge branch 'dev' into feat/auto-accept-permissions 2026-02-16 21:50:34 -05:00
Alex Yaroshuk
70b555472e refactor, add caching 2026-02-14 01:13:28 +08:00
Alex Yaroshuk
e514919cc4 changelog refactor 2026-02-11 22:23:39 +08:00
LukeParkerDev
a90e8de050 add missing return 2026-02-11 13:24:17 +10:00
Alex Yaroshuk
ba5121ce0b Merge remote-tracking branch 'upstream/dev' into feat/changelog 2026-02-11 05:06:54 +08:00
Aiden Cline
eabf770053 Merge branch 'dev' into utilize-family-in-dialog 2026-02-10 14:43:15 -06:00
Dax
86d7bdc542 Merge branch 'dev' into feat/auto-accept-permissions 2026-02-09 10:55:01 -05:00
Dax
d3ab78bba0 Merge branch 'dev' into feat/auto-accept-permissions 2026-02-09 10:04:40 -05:00
Dax Raad
a531f3f36d core: run command build agent now auto-accepts file edits to reduce workflow interruptions while still requiring confirmation for bash commands 2026-02-07 20:00:09 -05:00
Dax Raad
bb3382311d tui: standardize autoedit indicator text styling to match other status labels 2026-02-07 19:57:45 -05:00
Dax Raad
ad545d0cc9 tui: allow auto-accepting only edit permissions instead of all permissions 2026-02-07 19:52:53 -05:00
Dax Raad
ac244b1458 tui: add searchable 'toggle' keywords to command palette and show current state in toggle titles 2026-02-07 17:03:34 -05:00
Dax Raad
f202536b65 tui: show enable/disable state in permission toggle and make it searchable by 'toggle permissions' 2026-02-07 16:57:48 -05:00
Dax Raad
405cc3f610 tui: streamline permission toggle command naming and add keyboard shortcut support
Rename 'Toggle autoaccept permissions' to 'Toggle permissions' for clarity
and move the command to the Agent category for better discoverability.
Add permission_auto_accept_toggle keybind to enable keyboard shortcut
toggling of auto-accept mode for permission requests.
2026-02-07 16:51:55 -05:00
Dax Raad
878c1b8c2d feat(tui): add auto-accept mode for permission requests
Add a toggleable auto-accept mode that automatically accepts all incoming
permission requests with a 'once' reply. This is useful for users who want
to streamline their workflow when they trust the agent's actions.

Changes:
- Add permission_auto_accept keybind (default: shift+tab) to config
- Remove default for agent_cycle_reverse (was shift+tab)
- Add auto-accept logic in sync.tsx to auto-reply when enabled
- Add command bar action to toggle auto-accept mode (copy: "Toggle autoaccept permissions")
- Add visual indicator showing 'auto-accept' when active
- Store auto-accept state in KV for persistence across sessions
2026-02-07 16:44:39 -05:00
Alex Yaroshuk
d8bcfd90d3 trnslations, couple more fixes 2026-02-04 21:06:27 +08:00
Alex Yaroshuk
954d31903f fix ui 2026-02-04 20:50:55 +08:00
Alex Yaroshuk
1587d93b29 fixes 2026-02-04 20:35:06 +08:00
Alex Yaroshuk
d364c43916 style fixes 2026-02-04 20:15:02 +08:00
Alex Yaroshuk
72eec20437 add links 2026-02-04 17:12:41 +08:00
Alex Yaroshuk
4503bde1cc fix styling, add scrollbar 2026-02-04 16:59:00 +08:00
Alex Yaroshuk
1abc228e95 add timetsamps 2026-02-04 16:52:19 +08:00
Alex Yaroshuk
991e823039 style fixes 2026-02-04 16:39:01 +08:00
Alex Yaroshuk
62fa5c1314 fix styles 2026-02-04 16:28:09 +08:00
Alex Yaroshuk
93b9e47c05 changelog v1 2026-02-04 15:54:47 +08:00
Aiden Cline
bb4d978684 feat: update tui model dialog to utilize model family to reduce noise in list 2026-02-03 15:48:40 -06:00
811 changed files with 44938 additions and 10738 deletions

View File

@@ -59,43 +59,10 @@ jobs:
{
"permission": {
"*": "deny",
"read": {
"*": "deny",
"packages/web/src/content/docs": "allow",
"packages/web/src/content/docs/*": "allow",
"packages/web/src/content/docs/*.mdx": "allow",
"packages/web/src/content/docs/*/*.mdx": "allow",
".opencode": "allow",
".opencode/agent": "allow",
".opencode/glossary": "allow",
".opencode/agent/translator.md": "allow",
".opencode/glossary/*.md": "allow"
},
"edit": {
"*": "deny",
"packages/web/src/content/docs/*/*.mdx": "allow"
},
"glob": {
"*": "deny",
"packages/web/src/content/docs*": "allow",
".opencode/glossary*": "allow"
},
"task": {
"*": "deny",
"translator": "allow"
}
},
"agent": {
"translator": {
"permission": {
"*": "deny",
"read": {
"*": "deny",
".opencode/agent/translator.md": "allow",
".opencode/glossary/*.md": "allow"
}
}
}
"read": "allow",
"edit": "allow",
"glob": "allow",
"task": "allow"
}
}
run: |

View File

@@ -99,7 +99,6 @@ jobs:
with:
name: opencode-cli
path: packages/opencode/dist
outputs:
version: ${{ needs.version.outputs.version }}
@@ -240,11 +239,131 @@ jobs:
APPLE_API_KEY: ${{ secrets.APPLE_API_KEY }}
APPLE_API_KEY_PATH: ${{ runner.temp }}/apple-api-key.p8
build-electron:
needs:
- build-cli
- version
continue-on-error: false
strategy:
fail-fast: false
matrix:
settings:
- host: macos-latest
target: x86_64-apple-darwin
platform_flag: --mac --x64
- host: macos-latest
target: aarch64-apple-darwin
platform_flag: --mac --arm64
- host: "blacksmith-4vcpu-windows-2025"
target: x86_64-pc-windows-msvc
platform_flag: --win
- host: "blacksmith-4vcpu-ubuntu-2404"
target: x86_64-unknown-linux-gnu
platform_flag: --linux
- host: "blacksmith-4vcpu-ubuntu-2404"
target: aarch64-unknown-linux-gnu
platform_flag: --linux
runs-on: ${{ matrix.settings.host }}
# if: github.ref_name == 'beta'
steps:
- uses: actions/checkout@v3
- uses: apple-actions/import-codesign-certs@v2
if: runner.os == 'macOS'
with:
keychain: build
p12-file-base64: ${{ secrets.APPLE_CERTIFICATE }}
p12-password: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
- name: Setup Apple API Key
if: runner.os == 'macOS'
run: echo "${{ secrets.APPLE_API_KEY_PATH }}" > $RUNNER_TEMP/apple-api-key.p8
- uses: ./.github/actions/setup-bun
- uses: actions/setup-node@v4
with:
node-version: "24"
- name: Cache apt packages
if: contains(matrix.settings.host, 'ubuntu')
uses: actions/cache@v4
with:
path: ~/apt-cache
key: ${{ runner.os }}-${{ matrix.settings.target }}-apt-electron-${{ hashFiles('.github/workflows/publish.yml') }}
restore-keys: |
${{ runner.os }}-${{ matrix.settings.target }}-apt-electron-
- name: Install dependencies (ubuntu only)
if: contains(matrix.settings.host, 'ubuntu')
run: |
mkdir -p ~/apt-cache && chmod -R a+rw ~/apt-cache
sudo apt-get update
sudo apt-get install -y --no-install-recommends -o dir::cache::archives="$HOME/apt-cache" rpm
sudo chmod -R a+rw ~/apt-cache
- name: Setup git committer
id: committer
uses: ./.github/actions/setup-git-committer
with:
opencode-app-id: ${{ vars.OPENCODE_APP_ID }}
opencode-app-secret: ${{ secrets.OPENCODE_APP_SECRET }}
- name: Prepare
run: bun ./scripts/prepare.ts
working-directory: packages/desktop-electron
env:
OPENCODE_VERSION: ${{ needs.version.outputs.version }}
OPENCODE_CHANNEL: ${{ (github.ref_name == 'beta' && 'beta') || 'prod' }}
RUST_TARGET: ${{ matrix.settings.target }}
GH_TOKEN: ${{ github.token }}
GITHUB_RUN_ID: ${{ github.run_id }}
- name: Build
run: bun run build
working-directory: packages/desktop-electron
env:
OPENCODE_CHANNEL: ${{ (github.ref_name == 'beta' && 'beta') || 'prod' }}
- name: Package and publish
if: needs.version.outputs.release
run: npx electron-builder ${{ matrix.settings.platform_flag }} --publish always --config electron-builder.config.ts
working-directory: packages/desktop-electron
timeout-minutes: 60
env:
OPENCODE_CHANNEL: ${{ (github.ref_name == 'beta' && 'beta') || 'prod' }}
GH_TOKEN: ${{ steps.committer.outputs.token }}
CSC_LINK: ${{ secrets.APPLE_CERTIFICATE }}
CSC_KEY_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
APPLE_API_KEY: ${{ runner.temp }}/apple-api-key.p8
APPLE_API_KEY_ID: ${{ secrets.APPLE_API_KEY }}
APPLE_API_ISSUER: ${{ secrets.APPLE_API_ISSUER }}
- name: Package (no publish)
if: ${{ !needs.version.outputs.release }}
run: npx electron-builder ${{ matrix.settings.platform_flag }} --publish never --config electron-builder.config.ts
working-directory: packages/desktop-electron
timeout-minutes: 60
env:
OPENCODE_CHANNEL: ${{ (github.ref_name == 'beta' && 'beta') || 'prod' }}
- uses: actions/upload-artifact@v4
with:
name: opencode-electron-${{ matrix.settings.target }}
path: packages/desktop-electron/dist/*
- uses: actions/upload-artifact@v4
if: needs.version.outputs.release
with:
name: latest-yml-${{ matrix.settings.target }}
path: packages/desktop-electron/dist/latest*.yml
publish:
needs:
- version
- build-cli
- build-tauri
- build-electron
runs-on: blacksmith-4vcpu-ubuntu-2404
steps:
- uses: actions/checkout@v3
@@ -281,6 +400,12 @@ jobs:
name: opencode-cli
path: packages/opencode/dist
- uses: actions/download-artifact@v4
if: needs.version.outputs.release
with:
pattern: latest-yml-*
path: /tmp/latest-yml
- name: Cache apt packages (AUR)
uses: actions/cache@v4
with:
@@ -308,3 +433,4 @@ jobs:
GITHUB_TOKEN: ${{ steps.committer.outputs.token }}
GH_REPO: ${{ needs.version.outputs.repo }}
NPM_CONFIG_PROVENANCE: false
LATEST_YML_DIR: /tmp/latest-yml

38
.opencode/glossary/tr.md Normal file
View File

@@ -0,0 +1,38 @@
# tr Glossary
## Sources
- PR #15835: https://github.com/anomalyco/opencode/pull/15835
## Do Not Translate (Locale Additions)
- `OpenCode` (preserve casing in prose, docs, and UI copy)
- Keep lowercase `opencode` in commands, package names, paths, URLs, and other exact identifiers
- `<TAB>` stays the literal key token in code blocks; use `Tab` for the nearby explanatory label in prose
- Commands, flags, file paths, and code literals (keep exactly as written)
## Preferred Terms
These are PR-backed wording preferences and may evolve.
| English / Context | Preferred | Notes |
| ------------------------- | --------------------------------------- | ------------------------------------------------------------- |
| available in beta | `beta olarak mevcut` | Prefer this over `beta olarak kullanılabilir` |
| privacy-first | `Gizlilik öncelikli tasarlandı` | Prefer this over `Önce gizlilik için tasarlandı` |
| connect your local models | `yerel modellerinizi bağlayabilirsiniz` | Use the fuller, more direct action phrase |
| `<TAB>` key label | `Tab` | Use `Tab` in prose; keep `<TAB>` in literal UI or code blocks |
| cross-platform | `cross-platform (tüm platformlarda)` | Keep the English term, add a short clarification when helpful |
## Guidance
- Prefer natural Turkish phrasing over literal translation
- Merge broken sentence fragments into one clear sentence when the source is a single thought
- Keep product naming consistent: `OpenCode` in prose, `opencode` only for exact technical identifiers
- When an English technical term is intentionally kept, add a short Turkish clarification only if it improves readability
## Avoid
- Avoid `beta olarak kullanılabilir` when `beta olarak mevcut` fits
- Avoid `Önce gizlilik için tasarlandı`; use the more natural reviewed wording instead
- Avoid `Sekme` for the translated key label in prose when referring to `<TAB>`
- Avoid changing `opencode` to `OpenCode` inside commands, URLs, package names, or code literals

View File

@@ -20,6 +20,17 @@
Prefer single word names for variables and functions. Only use multiple words if necessary.
### Naming Enforcement (Read This)
THIS RULE IS MANDATORY FOR AGENT WRITTEN CODE.
- Use single word names by default for new locals, params, and helper functions.
- Multi-word names are allowed only when a single word would be unclear or ambiguous.
- Do not introduce new camelCase compounds when a short single-word alternative is clear.
- Before finishing edits, review touched lines and shorten newly introduced identifiers where possible.
- Good short names to prefer: `pid`, `cfg`, `err`, `opts`, `dir`, `root`, `child`, `state`, `timeout`.
- Examples to avoid unless truly required: `inputPID`, `existingClient`, `connectTimeout`, `workerPath`.
```ts
// Good
const foo = 1
@@ -111,3 +122,7 @@ const table = sqliteTable("session", {
- Avoid mocks as much as possible
- Test actual implementation, do not duplicate logic into tests
- Tests cannot run from repo root (guard: `do-not-run-tests-from-root`); run from package dirs like `packages/opencode`.
## Type Checking
- Always run `bun typecheck` from package directories (e.g., `packages/opencode`), never `tsc` directly.

View File

@@ -27,6 +27,7 @@
<a href="README.ja.md">日本語</a> |
<a href="README.pl.md">Polski</a> |
<a href="README.ru.md">Русский</a> |
<a href="README.bs.md">Bosanski</a> |
<a href="README.ar.md">العربية</a> |
<a href="README.no.md">Norsk</a> |
<a href="README.br.md">Português (Brasil)</a> |

1672
bun.lock

File diff suppressed because it is too large Load Diff

6
flake.lock generated
View File

@@ -2,11 +2,11 @@
"nodes": {
"nixpkgs": {
"locked": {
"lastModified": 1770812194,
"narHash": "sha256-OH+lkaIKAvPXR3nITO7iYZwew2nW9Y7Xxq0yfM/UcUU=",
"lastModified": 1772091128,
"narHash": "sha256-TnrYykX8Mf/Ugtkix6V+PjW7miU2yClA6uqWl/v6KWM=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "8482c7ded03bae7550f3d69884f1e611e3bd19e8",
"rev": "3f0336406035444b4a24b942788334af5f906259",
"type": "github"
},
"original": {

View File

@@ -8,6 +8,7 @@ import type { Context as GitHubContext } from "@actions/github/lib/context"
import type { IssueCommentEvent, PullRequestReviewCommentEvent } from "@octokit/webhooks-types"
import { createOpencodeClient } from "@opencode-ai/sdk"
import { spawn } from "node:child_process"
import { setTimeout as sleep } from "node:timers/promises"
type GitHubAuthor = {
login: string
@@ -281,7 +282,7 @@ async function assertOpencodeConnected() {
connected = true
break
} catch (e) {}
await Bun.sleep(300)
await sleep(300)
} while (retry++ < 30)
if (!connected) {

View File

@@ -118,7 +118,6 @@ const ZEN_LITE_PRICE = new sst.Linkable("ZEN_LITE_PRICE", {
price: zenLitePrice.id,
},
})
const ZEN_LITE_LIMITS = new sst.Secret("ZEN_LITE_LIMITS")
const zenBlackProduct = new stripe.Product("ZenBlack", {
name: "OpenCode Black",
@@ -142,7 +141,6 @@ const ZEN_BLACK_PRICE = new sst.Linkable("ZEN_BLACK_PRICE", {
plan20: zenBlackPrice20.id,
},
})
const ZEN_BLACK_LIMITS = new sst.Secret("ZEN_BLACK_LIMITS")
const ZEN_MODELS = [
new sst.Secret("ZEN_MODELS1"),
@@ -215,9 +213,8 @@ new sst.cloudflare.x.SolidStart("Console", {
AWS_SES_ACCESS_KEY_ID,
AWS_SES_SECRET_ACCESS_KEY,
ZEN_BLACK_PRICE,
ZEN_BLACK_LIMITS,
ZEN_LITE_PRICE,
ZEN_LITE_LIMITS,
new sst.Secret("ZEN_LIMITS"),
new sst.Secret("ZEN_SESSION_SECRET"),
...ZEN_MODELS,
...($dev

View File

@@ -1,8 +1,8 @@
{
"nodeModules": {
"x86_64-linux": "sha256-dZoLhWe4smBsOF7WczMySLXSAB1YRO1vfhiOCL1rBf0=",
"aarch64-linux": "sha256-J7nIz1xuVZEHun5WRZkYRySz29B0A8g5g0RRxnIWTYU=",
"aarch64-darwin": "sha256-R2PuhX+EjUBuLE8MF0G0fcUwNaU+5n6V6uVeK89ulzw=",
"x86_64-darwin": "sha256-Bvzfz9TsTpYriZNLSLgpNcNb+BgtkgpjoWqdOtF2IBg="
"x86_64-linux": "sha256-TnrYykX8Mf/Ugtkix6V",
"aarch64-linux": "sha256-TnrYykX8Mf/Ugtkix6V",
"aarch64-darwin": "sha256-TnrYykX8Mf/Ugtkix6V",
"x86_64-darwin": "sha256-TnrYykX8Mf/Ugtkix6V"
}
}

View File

@@ -31,6 +31,7 @@ stdenvNoCC.mkDerivation {
../package.json
../patches
../install # required by desktop build (cli.rs include_str!)
../.github/TEAM_MEMBERS # required by @opencode-ai/script
]
);
};

View File

@@ -9,6 +9,7 @@
"dev": "bun run --cwd packages/opencode --conditions=browser src/index.ts",
"dev:desktop": "bun --cwd packages/desktop tauri dev",
"dev:web": "bun --cwd packages/app dev",
"dev:storybook": "bun --cwd packages/storybook storybook",
"typecheck": "bun turbo typecheck",
"prepare": "husky",
"random": "echo 'Random script'",
@@ -35,7 +36,7 @@
"@tsconfig/bun": "1.0.9",
"@cloudflare/workers-types": "4.20251008.0",
"@openauthjs/openauth": "0.0.0-20250322224806",
"@pierre/diffs": "1.1.0-beta.13",
"@pierre/diffs": "1.1.0-beta.18",
"@solid-primitives/storage": "4.3.3",
"@tailwindcss/vite": "4.1.11",
"diff": "8.0.2",
@@ -70,12 +71,13 @@
"@actions/artifact": "5.0.1",
"@tsconfig/bun": "catalog:",
"@types/mime-types": "3.0.1",
"@typescript/native-preview": "catalog:",
"glob": "13.0.5",
"husky": "9.1.7",
"prettier": "3.6.2",
"semver": "^7.6.0",
"sst": "3.18.10",
"turbo": "2.5.6"
"turbo": "2.8.13"
},
"dependencies": {
"@aws-sdk/client-s3": "3.933.0",
@@ -98,7 +100,8 @@
"protobufjs",
"tree-sitter",
"tree-sitter-bash",
"web-tree-sitter"
"web-tree-sitter",
"electron"
],
"overrides": {
"@types/bun": "catalog:",

View File

@@ -0,0 +1,515 @@
# CreateEffect Simplification Implementation Spec
Reduce reactive misuse across `packages/app`.
---
## Context
This work targets `packages/app/src`, which currently has 101 `createEffect` calls across 37 files.
The biggest clusters are `pages/session.tsx` (19), `pages/layout.tsx` (13), `pages/session/file-tabs.tsx` (6), and several context providers that mirror one store into another.
Key issues from the audit:
- Derived state is being written through effects instead of computed directly
- Session and file resets are handled by watch-and-clear effects instead of keyed state boundaries
- User-driven actions are hidden inside reactive effects
- Context layers mirror and hydrate child stores with multiple sync effects
- Several areas repeat the same imperative trigger pattern in multiple effects
Keep the implementation focused on removing unnecessary effects, not on broad UI redesign.
## Goals
- Cut high-churn `createEffect` usage in the hottest files first
- Replace effect-driven derived state with reactive derivation
- Replace reset-on-key effects with keyed ownership boundaries
- Move event-driven work to direct actions and write paths
- Remove mirrored store hydration where a single source of truth can exist
- Leave necessary external sync effects in place, but make them narrower and clearer
## Non-Goals
- Do not rewrite unrelated component structure just to reduce the count
- Do not change product behavior, navigation flow, or persisted data shape unless required for a cleaner write boundary
- Do not remove effects that bridge to DOM, editors, polling, or external APIs unless there is a clearly safer equivalent
- Do not attempt a repo-wide cleanup outside `packages/app`
## Effect Taxonomy And Replacement Rules
Use these rules during implementation.
### Prefer `createMemo`
Use `createMemo` when the target value is pure derived state from other signals or stores.
Do this when an effect only reads reactive inputs and writes another reactive value that could be computed instead.
Apply this to:
- `packages/app/src/pages/session.tsx:141`
- `packages/app/src/pages/layout.tsx:557`
- `packages/app/src/components/terminal.tsx:261`
- `packages/app/src/components/session/session-header.tsx:309`
Rules:
- If no external system is touched, do not use `createEffect`
- Derive once, then read the memo where needed
- If normalization is required, prefer normalizing at the write boundary before falling back to a memo
### Prefer Keyed Remounts
Use keyed remounts when local UI state should reset because an identity changed.
Do this with `sessionKey`, `scope()`, or another stable identity instead of watching the key and manually clearing signals.
Apply this to:
- `packages/app/src/pages/session.tsx:325`
- `packages/app/src/pages/session.tsx:336`
- `packages/app/src/pages/session.tsx:477`
- `packages/app/src/pages/session.tsx:869`
- `packages/app/src/pages/session.tsx:963`
- `packages/app/src/pages/session/message-timeline.tsx:149`
- `packages/app/src/context/file.tsx:100`
Rules:
- If the desired behavior is "new identity, fresh local state," key the owner subtree
- Keep state local to the keyed boundary so teardown and recreation handle the reset naturally
### Prefer Event Handlers And Actions
Use direct handlers, store actions, and async command functions when work happens because a user clicked, selected, reloaded, or navigated.
Do this when an effect is just watching for a flag change, command token, or event-bus signal to trigger imperative logic.
Apply this to:
- `packages/app/src/pages/layout.tsx:484`
- `packages/app/src/pages/layout.tsx:652`
- `packages/app/src/pages/layout.tsx:776`
- `packages/app/src/pages/layout.tsx:1489`
- `packages/app/src/pages/layout.tsx:1519`
- `packages/app/src/components/file-tree.tsx:328`
- `packages/app/src/pages/session/terminal-panel.tsx:55`
- `packages/app/src/context/global-sync.tsx:148`
- Duplicated trigger sets in:
- `packages/app/src/pages/session/review-tab.tsx:122`
- `packages/app/src/pages/session/review-tab.tsx:130`
- `packages/app/src/pages/session/review-tab.tsx:138`
- `packages/app/src/pages/session/file-tabs.tsx:367`
- `packages/app/src/pages/session/file-tabs.tsx:378`
- `packages/app/src/pages/session/file-tabs.tsx:389`
- `packages/app/src/pages/session/use-session-hash-scroll.ts:144`
- `packages/app/src/pages/session/use-session-hash-scroll.ts:149`
- `packages/app/src/pages/session/use-session-hash-scroll.ts:167`
Rules:
- If the trigger is user intent, call the action at the source of that intent
- If the same imperative work is triggered from multiple places, extract one function and call it directly
### Prefer `onMount` And `onCleanup`
Use `onMount` and `onCleanup` for lifecycle-only setup and teardown.
This is the right fit for subscriptions, one-time wiring, timers, and imperative integration that should not rerun for ordinary reactive changes.
Use this when:
- Setup should happen once per owner lifecycle
- Cleanup should always pair with teardown
- The work is not conceptually derived state
### Keep `createEffect` When It Is A Real Bridge
Keep `createEffect` when it synchronizes reactive data to an external imperative sink.
Examples that should remain, though they may be narrowed or split:
- DOM/editor sync in `packages/app/src/components/prompt-input.tsx:690`
- Scroll sync in `packages/app/src/pages/session.tsx:685`
- Scroll/hash sync in `packages/app/src/pages/session/use-session-hash-scroll.ts:149`
- External sync in:
- `packages/app/src/context/language.tsx:207`
- `packages/app/src/context/settings.tsx:110`
- `packages/app/src/context/sdk.tsx:26`
- Polling in:
- `packages/app/src/components/status-popover.tsx:59`
- `packages/app/src/components/dialog-select-server.tsx:273`
Rules:
- Keep the effect single-purpose
- Make dependencies explicit and narrow
- Avoid writing back into the same reactive graph unless absolutely required
## Implementation Plan
### Phase 0: Classification Pass
Before changing code, tag each targeted effect as one of: derive, reset, event, lifecycle, or external bridge.
Acceptance criteria:
- Every targeted effect in this spec is tagged with a replacement strategy before refactoring starts
- Shared helpers to be introduced are identified up front to avoid repeating patterns
### Phase 1: Derived-State Cleanup
Tackle highest-value, lowest-risk derived-state cleanup first.
Priority items:
- Normalize tabs at write boundaries and remove `packages/app/src/pages/session.tsx:141`
- Stop syncing `workspaceOrder` in `packages/app/src/pages/layout.tsx:557`
- Make prompt slash filtering reactive so `packages/app/src/components/prompt-input.tsx:652` can be removed
- Replace other obvious derived-state effects in terminal and session header
Acceptance criteria:
- No behavior change in tab ordering, prompt filtering, terminal display, or header state
- Targeted derived-state effects are deleted, not just moved
### Phase 2: Keyed Reset Cleanup
Replace reset-on-key effects with keyed ownership boundaries.
Priority items:
- Key session-scoped UI and state by `sessionKey`
- Key file-scoped state by `scope()`
- Remove manual clear-and-reseed effects in session and file context
Acceptance criteria:
- Switching session or file scope recreates the intended local state cleanly
- No stale state leaks across session or scope changes
- Target reset effects are deleted
### Phase 3: Event-Driven Work Extraction
Move event-driven work out of reactive effects.
Priority items:
- Replace `globalStore.reload` effect dispatching with direct calls
- Split mixed-responsibility effect in `packages/app/src/pages/layout.tsx:1489`
- Collapse duplicated imperative trigger triplets into single functions
- Move file-tree and terminal-panel imperative work to explicit handlers
Acceptance criteria:
- User-triggered behavior still fires exactly once per intended action
- No effect remains whose only job is to notice a command-like state and trigger an imperative function
### Phase 4: Context Ownership Cleanup
Remove mirrored child-store hydration patterns.
Priority items:
- Remove child-store hydration mirrors in `packages/app/src/context/global-sync/child-store.ts:184`, `:190`, `:193`
- Simplify mirror logic in `packages/app/src/context/global-sync.tsx:130`, `:138`
- Revisit `packages/app/src/context/layout.tsx:424` if it still mirrors instead of deriving
Acceptance criteria:
- There is one clear source of truth for each synced value
- Child stores no longer need effect-based hydration to stay consistent
- Initialization and updates both work without manual mirror effects
### Phase 5: Cleanup And Keeper Review
Clean up remaining targeted hotspots and narrow the effects that should stay.
Acceptance criteria:
- Remaining `createEffect` calls in touched files are all true bridges or clearly justified lifecycle sync
- Mixed-responsibility effects are split into smaller units where still needed
## Detailed Work Items By Area
### 1. Normalize Tab State
Files:
- `packages/app/src/pages/session.tsx:141`
Work:
- Move tab normalization into the functions that create, load, or update tab state
- Make readers consume already-normalized tab data
- Remove the effect that rewrites derived tab state after the fact
Rationale:
- Tabs should become valid when written, not be repaired later
- This removes a feedback loop and makes state easier to trust
Acceptance criteria:
- The effect at `packages/app/src/pages/session.tsx:141` is removed
- Newly created and restored tabs are normalized before they enter local state
- Tab rendering still matches current behavior for valid and edge-case inputs
### 2. Key Session-Owned State
Files:
- `packages/app/src/pages/session.tsx:325`
- `packages/app/src/pages/session.tsx:336`
- `packages/app/src/pages/session.tsx:477`
- `packages/app/src/pages/session.tsx:869`
- `packages/app/src/pages/session.tsx:963`
- `packages/app/src/pages/session/message-timeline.tsx:149`
Work:
- Identify state that should reset when `sessionKey` changes
- Move that state under a keyed subtree or keyed owner boundary
- Remove effects that watch `sessionKey` just to clear local state, refs, or temporary UI flags
Rationale:
- Session identity already defines the lifetime of this UI state
- Keyed ownership makes reset behavior automatic and easier to reason about
Acceptance criteria:
- The targeted reset effects are removed
- Changing sessions resets only the intended session-local state
- Scroll and editor state that should persist are not accidentally reset
### 3. Derive Workspace Order
Files:
- `packages/app/src/pages/layout.tsx:557`
Work:
- Stop writing `workspaceOrder` from live workspace data in an effect
- Represent user overrides separately from live workspace data
- Compute effective order from current data plus overrides with a memo or pure helper
Rationale:
- Persisted user intent and live source data should not mirror each other through an effect
- A computed effective order avoids drift and racey resync behavior
Acceptance criteria:
- The effect at `packages/app/src/pages/layout.tsx:557` is removed
- Workspace order updates correctly when workspaces appear, disappear, or are reordered by the user
- User overrides persist without requiring a sync-back effect
### 4. Remove Child-Store Mirrors
Files:
- `packages/app/src/context/global-sync.tsx:130`
- `packages/app/src/context/global-sync.tsx:138`
- `packages/app/src/context/global-sync.tsx:148`
- `packages/app/src/context/global-sync/child-store.ts:184`
- `packages/app/src/context/global-sync/child-store.ts:190`
- `packages/app/src/context/global-sync/child-store.ts:193`
- `packages/app/src/context/layout.tsx:424`
Work:
- Trace the actual ownership of global and child store values
- Replace hydration and mirror effects with explicit initialization and direct updates
- Remove the `globalStore.reload` event-bus pattern and call the needed reload paths directly
Rationale:
- Mirrors make it hard to tell which state is authoritative
- Event-bus style state toggles hide control flow and create accidental reruns
Acceptance criteria:
- Child store hydration no longer depends on effect-based copying
- Reload work can be followed from the event source to the handler without a reactive relay
- State remains correct on first load, child creation, and subsequent updates
### 5. Key File-Scoped State
Files:
- `packages/app/src/context/file.tsx:100`
Work:
- Move file-scoped local state under a boundary keyed by `scope()`
- Remove any effect that watches `scope()` only to reset file-local state
Rationale:
- File scope changes are identity changes
- Keyed ownership gives a cleaner reset than manual clear logic
Acceptance criteria:
- The effect at `packages/app/src/context/file.tsx:100` is removed
- Switching scopes resets only scope-local state
- No previous-scope data appears after a scope change
### 6. Split Layout Side Effects
Files:
- `packages/app/src/pages/layout.tsx:1489`
- Related event-driven effects near `packages/app/src/pages/layout.tsx:484`, `:652`, `:776`, `:1519`
Work:
- Break the mixed-responsibility effect at `:1489` into direct actions and smaller bridge effects only where required
- Move user-triggered branches into the actual command or handler that causes them
- Remove any branch that only exists because one effect is handling unrelated concerns
Rationale:
- Mixed effects hide cause and make reruns hard to predict
- Smaller units reduce accidental coupling and make future cleanup safer
Acceptance criteria:
- The effect at `packages/app/src/pages/layout.tsx:1489` no longer mixes unrelated responsibilities
- Event-driven branches execute from direct handlers
- Remaining effects in this area each have one clear external sync purpose
### 7. Remove Duplicate Triggers
Files:
- `packages/app/src/pages/session/review-tab.tsx:122`
- `packages/app/src/pages/session/review-tab.tsx:130`
- `packages/app/src/pages/session/review-tab.tsx:138`
- `packages/app/src/pages/session/file-tabs.tsx:367`
- `packages/app/src/pages/session/file-tabs.tsx:378`
- `packages/app/src/pages/session/file-tabs.tsx:389`
- `packages/app/src/pages/session/use-session-hash-scroll.ts:144`
- `packages/app/src/pages/session/use-session-hash-scroll.ts:149`
- `packages/app/src/pages/session/use-session-hash-scroll.ts:167`
Work:
- Extract one explicit imperative function per behavior
- Call that function from each source event instead of replicating the same effect pattern multiple times
- Preserve the scroll-sync effect that is truly syncing with the DOM, but remove duplicate trigger scaffolding around it
Rationale:
- Duplicate triggers make it easy to miss a case or fire twice
- One named action is easier to test and reason about
Acceptance criteria:
- Repeated imperative effect triplets are collapsed into shared functions
- Scroll behavior still works, including hash-based navigation
- No duplicate firing is introduced
### 8. Make Prompt Filtering Reactive
Files:
- `packages/app/src/components/prompt-input.tsx:652`
- Keep `packages/app/src/components/prompt-input.tsx:690` as needed
Work:
- Convert slash filtering into a pure reactive derivation from the current input and candidate command list
- Keep only the editor or DOM bridge effect if it is still needed for imperative syncing
Rationale:
- Filtering is classic derived state
- It should not need an effect if it can be computed from current inputs
Acceptance criteria:
- The effect at `packages/app/src/components/prompt-input.tsx:652` is removed
- Filtered slash-command results update correctly as the input changes
- The editor sync effect at `:690` still behaves correctly
### 9. Clean Up Smaller Derived-State Cases
Files:
- `packages/app/src/components/terminal.tsx:261`
- `packages/app/src/components/session/session-header.tsx:309`
Work:
- Replace effect-written local state with memos or inline derivation
- Remove intermediate setters when the value can be computed directly
Rationale:
- These are low-risk wins that reinforce the same pattern
- They also help keep follow-up cleanup consistent
Acceptance criteria:
- Targeted effects are removed
- UI output remains unchanged under the same inputs
## Verification And Regression Checks
Run focused checks after each phase, not only at the end.
### Suggested Verification
- Switch between sessions rapidly and confirm local session UI resets only where intended
- Open, close, and reorder tabs and confirm order and normalization remain stable
- Change workspaces, reload workspace data, and verify effective ordering is correct
- Change file scope and confirm stale file state does not bleed across scopes
- Trigger layout actions that previously depended on effects and confirm they still fire once
- Use slash commands in the prompt and verify filtering updates as you type
- Test review tab, file tab, and hash-scroll flows for duplicate or missing triggers
- Verify global sync initialization, reload, and child-store creation paths
### Regression Checks
- No accidental infinite reruns
- No double-firing network or command actions
- No lost cleanup for listeners, timers, or scroll handlers
- No preserved stale state after identity changes
- No removed effect that was actually bridging to DOM or an external API
If available, add or update tests around pure helpers introduced during this cleanup.
Favor tests for derived ordering, normalization, and action extraction, since those are easiest to lock down.
## Definition Of Done
This work is done when all of the following are true:
- The highest-leverage targets in this spec are implemented
- Each removed effect has been replaced by a clearer pattern: memo, keyed boundary, direct action, or lifecycle hook
- The "should remain" effects still exist only where they serve a real external sync purpose
- Touched files have fewer mixed-responsibility effects and clearer ownership of state
- Manual verification covers session switching, file scope changes, workspace ordering, prompt filtering, and reload flows
- No behavior regressions are found in the targeted areas
A reduced raw `createEffect` count is helpful, but it is not the main success metric.
The main success metric is clearer ownership and fewer effect-driven state repairs.
## Risks And Rollout Notes
Main risks:
- Keyed remounts can reset too much if state boundaries are drawn too high
- Store mirror removal can break initialization order if ownership is not mapped first
- Moving event work out of effects can accidentally skip triggers that were previously implicit
Rollout notes:
- Land in small phases, with each phase keeping the app behaviorally stable
- Prefer isolated PRs by phase or by file cluster, especially for context-store changes
- Review each remaining effect in touched files and leave it only if it clearly bridges to something external

View File

@@ -92,14 +92,19 @@ test("switching back to a project opens the latest workspace session", async ({
await expect(page).toHaveURL(new RegExp(`/${workspaceSlug}/session(?:[/?#]|$)`))
const created = await createSdk(workspaceDir)
.session.create()
.then((x) => x.data?.id)
if (!created) throw new Error(`Failed to create session for workspace: ${workspaceDir}`)
// Create a session by sending a prompt
const prompt = page.locator(promptSelector)
await expect(prompt).toBeVisible()
await prompt.fill("test")
await page.keyboard.press("Enter")
// Wait for the URL to update with the new session ID
await expect.poll(() => sessionIDFromUrl(page.url()) ?? "", { timeout: 15_000 }).not.toBe("")
const created = sessionIDFromUrl(page.url())
if (!created) throw new Error(`Failed to get session ID from url: ${page.url()}`)
sessionID = created
await page.goto(sessionPath(workspaceDir, created))
await expect(page.locator(promptSelector)).toBeVisible()
await expect(page).toHaveURL(new RegExp(`/${workspaceSlug}/session/${created}(?:[/?#]|$)`))
await openSidebar(page)

View File

@@ -142,6 +142,17 @@ test("default dock shows prompt input", async ({ page, sdk, gotoSession }) => {
})
})
test("auto-accept toggle works before first submit", async ({ page, gotoSession }) => {
await gotoSession()
const button = page.locator('[data-action="prompt-permissions"]').first()
await expect(button).toBeVisible()
await expect(button).toHaveAttribute("aria-pressed", "false")
await setAutoAccept(page, true)
await setAutoAccept(page, false)
})
test("blocked question flow unblocks after submit", async ({ page, sdk, gotoSession }) => {
await withDockSession(sdk, "e2e composer dock question", async (session) => {
await withDockSeed(sdk, session.id, async () => {

View File

@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/app",
"version": "1.2.15",
"version": "1.2.17",
"description": "",
"type": "module",
"exports": {

View File

@@ -145,6 +145,7 @@ try {
Object.assign(process.env, serverEnv)
process.env.AGENT = "1"
process.env.OPENCODE = "1"
process.env.OPENCODE_PID = String(process.pid)
const log = await import("../../opencode/src/util/log")
const install = await import("../../opencode/src/installation")

View File

@@ -0,0 +1,50 @@
import type { Platform } from "@/context/platform"
const REPO = "anomalyco/opencode"
const GITHUB_API_URL = `https://api.github.com/repos/${REPO}/releases`
const PER_PAGE = 30
const CACHE_TTL = 1000 * 60 * 30
const CACHE_KEY = "opencode.releases"
type Release = {
tag: string
body: string
date: string
}
function loadCache() {
const raw = localStorage.getItem(CACHE_KEY)
return raw ? JSON.parse(raw) : null
}
function saveCache(data: { releases: Release[]; timestamp: number }) {
localStorage.setItem(CACHE_KEY, JSON.stringify(data))
}
export async function fetchReleases(platform: Platform): Promise<{ releases: Release[] }> {
const now = Date.now()
const cached = loadCache()
if (cached && now - cached.timestamp < CACHE_TTL) {
return { releases: cached.releases }
}
const fetcher = platform.fetch ?? fetch
const res = await fetcher(`${GITHUB_API_URL}?per_page=${PER_PAGE}`, {
headers: { Accept: "application/vnd.github.v3+json" },
}).then((r) => (r.ok ? r.json() : Promise.reject(new Error("Failed to load"))))
const releases = (Array.isArray(res) ? res : []).map((r) => ({
tag: r.tag_name ?? "Unknown",
body: (r.body ?? "")
.replace(/#(\d+)/g, (_: string, id: string) => `[#${id}](https://github.com/anomalyco/opencode/pull/${id})`)
.replace(/@([a-zA-Z0-9_-]+)/g, (_: string, u: string) => `[@${u}](https://github.com/${u})`),
date: r.published_at ?? "",
}))
saveCache({ releases, timestamp: now })
return { releases }
}
export type { Release }

View File

@@ -7,8 +7,8 @@ import { MarkedProvider } from "@opencode-ai/ui/context/marked"
import { Font } from "@opencode-ai/ui/font"
import { ThemeProvider } from "@opencode-ai/ui/theme"
import { MetaProvider } from "@solidjs/meta"
import { Navigate, Route, Router } from "@solidjs/router"
import { ErrorBoundary, type JSX, lazy, type ParentProps, Show, Suspense } from "solid-js"
import { BaseRouterProps, Navigate, Route, Router } from "@solidjs/router"
import { Component, ErrorBoundary, type JSX, lazy, type ParentProps, Show, Suspense } from "solid-js"
import { CommandProvider } from "@/context/command"
import { CommentsProvider } from "@/context/comments"
import { FileProvider } from "@/context/file"
@@ -28,6 +28,7 @@ import { TerminalProvider } from "@/context/terminal"
import DirectoryLayout from "@/pages/directory-layout"
import Layout from "@/pages/layout"
import { ErrorPage } from "./pages/error"
import { Dynamic } from "solid-js/web"
const Home = lazy(() => import("@/pages/home"))
const Session = lazy(() => import("@/pages/session"))
@@ -144,13 +145,15 @@ export function AppInterface(props: {
children?: JSX.Element
defaultServer: ServerConnection.Key
servers?: Array<ServerConnection.Any>
router?: Component<BaseRouterProps>
}) {
return (
<ServerProvider defaultServer={props.defaultServer} servers={props.servers}>
<ServerKey>
<GlobalSDKProvider>
<GlobalSyncProvider>
<Router
<Dynamic
component={props.router ?? Router}
root={(routerProps) => <RouterRoot appChildren={props.children}>{routerProps.children}</RouterRoot>}
>
<Route path="/" component={HomeRoute} />
@@ -158,7 +161,7 @@ export function AppInterface(props: {
<Route path="/" component={SessionIndexRoute} />
<Route path="/session/:id?" component={SessionRoute} />
</Route>
</Router>
</Dynamic>
</GlobalSyncProvider>
</GlobalSDKProvider>
</ServerKey>

View File

@@ -0,0 +1,150 @@
.dialog-changelog {
min-height: 500px;
display: flex;
flex-direction: column;
}
.dialog-changelog [data-slot="dialog-body"] {
flex: 1;
overflow: hidden;
display: flex;
flex-direction: column;
min-height: 0;
}
.dialog-changelog-list {
flex: 1;
overflow: hidden;
display: flex;
flex-direction: column;
min-height: 0;
}
.dialog-changelog-list [data-slot="list-scroll"] {
flex: 1;
overflow-y: auto;
scrollbar-width: thin;
scrollbar-color: var(--border-weak-base) transparent;
}
.dialog-changelog-list [data-slot="list-scroll"]::-webkit-scrollbar {
width: 10px;
height: 10px;
}
.dialog-changelog-list [data-slot="list-scroll"]::-webkit-scrollbar-track {
background: transparent;
border-radius: 5px;
}
.dialog-changelog-list [data-slot="list-scroll"]::-webkit-scrollbar-thumb {
background: var(--border-weak-base);
border-radius: 5px;
border: 3px solid transparent;
background-clip: padding-box;
}
.dialog-changelog-list [data-slot="list-scroll"]::-webkit-scrollbar-thumb:hover {
background: var(--border-weak-base);
}
.dialog-changelog-header {
padding: 8px 12px 8px 8px;
display: flex;
align-items: baseline;
gap: 8px;
position: sticky;
top: 0;
z-index: 10;
background: var(--surface-raised-stronger-non-alpha);
}
.dialog-changelog-header::after {
content: "";
position: absolute;
top: 100%;
left: 0;
right: 0;
height: 16px;
background: linear-gradient(to bottom, var(--surface-raised-stronger-non-alpha), transparent);
pointer-events: none;
opacity: 0;
transition: opacity 0.15s ease;
}
.dialog-changelog-header[data-stuck="true"]::after {
opacity: 1;
}
.dialog-changelog-version {
font-size: 20px;
font-weight: 600;
}
.dialog-changelog-date {
font-size: 12px;
font-weight: 400;
color: var(--text-weak);
}
.dialog-changelog-list [data-slot="list-item"] {
margin-bottom: 32px;
padding: 0;
border: none;
background: transparent;
cursor: default;
display: block;
text-align: left;
}
.dialog-changelog-list [data-slot="list-item"]:hover {
background: transparent;
}
.dialog-changelog-list [data-slot="list-item"]:focus {
outline: none;
}
.dialog-changelog-list [data-slot="list-item"]:focus-visible {
outline: 2px solid var(--focus-base);
outline-offset: 2px;
}
.dialog-changelog-content {
padding: 0 8px 24px;
}
.dialog-changelog-markdown h2 {
border-bottom: 1px solid var(--border-weak-base);
padding-bottom: 4px;
margin: 32px 0 12px 0;
font-size: 14px;
font-weight: 500;
text-transform: capitalize;
}
.dialog-changelog-markdown h2:first-child {
margin-top: 16px;
}
.dialog-changelog-markdown a.external-link {
color: var(--text-interactive-base);
font-weight: 500;
}
.dialog-changelog-markdown a.external-link[href^="https://github.com/anomalyco/opencode/pull/"],
.dialog-changelog-markdown a.external-link[href^="https://github.com/anomalyco/opencode/issues/"],
.dialog-changelog-markdown a.external-link[href^="https://github.com/"]
{
border-radius: 3px;
padding: 0 2px;
}
.dialog-changelog-markdown a.external-link[href^="https://github.com/anomalyco/opencode/pull/"]:hover,
.dialog-changelog-markdown a.external-link[href^="https://github.com/anomalyco/opencode/issues/"]:hover,
.dialog-changelog-markdown a.external-link[href^="https://github.com/"]:hover
{
background: var(--surface-weak-base);
}

View File

@@ -0,0 +1,43 @@
import { createResource, Suspense, ErrorBoundary, Show } from "solid-js"
import { Dialog } from "@opencode-ai/ui/dialog"
import { DataProvider } from "@opencode-ai/ui/context"
import { useLanguage } from "@/context/language"
import { usePlatform } from "@/context/platform"
import { fetchReleases } from "@/api/releases"
import { ReleaseList } from "@/components/release-list"
export function DialogChangelog() {
const language = useLanguage()
const platform = usePlatform()
const [data] = createResource(() => fetchReleases(platform))
return (
<Dialog size="x-large" transition title="Changelog">
<DataProvider data={{ session: [], session_status: {}, session_diff: {}, message: {}, part: {} }} directory="">
<div class="flex-1 min-h-0 flex flex-col">
<ErrorBoundary
fallback={(e) => (
<p class="text-text-weak p-6">
{e instanceof Error ? e.message : "Failed to load changelog"}
</p>
)}
>
<Suspense fallback={<p class="text-text-weak p-6">{language.t("common.loading")}...</p>}>
<Show
when={(data()?.releases.length ?? 0) > 0}
fallback={<p class="text-text-weak p-6">{language.t("common.noReleasesFound")}</p>}
>
<ReleaseList
releases={data()!.releases}
hasMore={false}
loadingMore={false}
onLoadMore={() => {}}
/>
</Show>
</Suspense>
</ErrorBoundary>
</div>
</DataProvider>
</Dialog>
)
}

View File

@@ -4,7 +4,6 @@ import { useDialog } from "@opencode-ai/ui/context/dialog"
import { Dialog } from "@opencode-ai/ui/dialog"
import { Icon } from "@opencode-ai/ui/icon"
import { IconButton } from "@opencode-ai/ui/icon-button"
import type { IconName } from "@opencode-ai/ui/icons/provider"
import { List, type ListRef } from "@opencode-ai/ui/list"
import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
import { Spinner } from "@opencode-ai/ui/spinner"
@@ -447,7 +446,7 @@ export function DialogConnectProvider(props: { provider: string }) {
>
<div class="flex flex-col gap-6 px-2.5 pb-3">
<div class="px-2.5 flex gap-4 items-center">
<ProviderIcon id={props.provider as IconName} class="size-5 shrink-0 icon-strong-base" />
<ProviderIcon id={props.provider} class="size-5 shrink-0 icon-strong-base" />
<div class="text-16-medium text-text-strong">
<Switch>
<Match when={props.provider === "anthropic" && method()?.label?.toLowerCase().includes("max")}>

View File

@@ -459,4 +459,4 @@ export function DialogSelectFile(props: { mode?: DialogSelectFileMode; onOpenFil
</List>
</Dialog>
)
}
}

View File

@@ -1,7 +1,6 @@
import { Button } from "@opencode-ai/ui/button"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { Dialog } from "@opencode-ai/ui/dialog"
import type { IconName } from "@opencode-ai/ui/icons/provider"
import { List, type ListRef } from "@opencode-ai/ui/list"
import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
import { Tag } from "@opencode-ai/ui/tag"
@@ -95,7 +94,7 @@ export const DialogSelectModelUnpaid: Component = () => {
>
{(i) => (
<div class="w-full flex items-center gap-x-3">
<ProviderIcon data-slot="list-item-extra-icon" id={i.id as IconName} />
<ProviderIcon data-slot="list-item-extra-icon" id={i.id} />
<span>{i.name}</span>
<Show when={i.id === "opencode"}>
<div class="text-14-regular text-text-weak">{language.t("dialog.provider.opencode.tagline")}</div>

View File

@@ -5,18 +5,12 @@ import { Dialog } from "@opencode-ai/ui/dialog"
import { List } from "@opencode-ai/ui/list"
import { Tag } from "@opencode-ai/ui/tag"
import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
import { iconNames, type IconName } from "@opencode-ai/ui/icons/provider"
import { DialogConnectProvider } from "./dialog-connect-provider"
import { useLanguage } from "@/context/language"
import { DialogCustomProvider } from "./dialog-custom-provider"
const CUSTOM_ID = "_custom"
function icon(id: string): IconName {
if (iconNames.includes(id as IconName)) return id as IconName
return "synthetic"
}
export const DialogSelectProvider: Component = () => {
const dialog = useDialog()
const providers = useProviders()
@@ -69,7 +63,7 @@ export const DialogSelectProvider: Component = () => {
>
{(i) => (
<div class="px-1.25 w-full flex items-center gap-x-3">
<ProviderIcon data-slot="list-item-extra-icon" id={icon(i.id)} />
<ProviderIcon data-slot="list-item-extra-icon" id={i.id} />
<span>{i.name}</span>
<Show when={i.id === "opencode"}>
<div class="text-14-regular text-text-weak">{language.t("dialog.provider.opencode.tagline")}</div>

View File

@@ -2,16 +2,25 @@ import { Component } from "solid-js"
import { Dialog } from "@opencode-ai/ui/dialog"
import { Tabs } from "@opencode-ai/ui/tabs"
import { Icon } from "@opencode-ai/ui/icon"
import { Button } from "@opencode-ai/ui/button"
import { useLanguage } from "@/context/language"
import { usePlatform } from "@/context/platform"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { SettingsGeneral } from "./settings-general"
import { SettingsKeybinds } from "./settings-keybinds"
import { SettingsProviders } from "./settings-providers"
import { SettingsModels } from "./settings-models"
import { SettingsArchive } from "./settings-archive"
import { DialogChangelog } from "@/components/dialog-changelog"
export const DialogSettings: Component = () => {
const language = useLanguage()
const platform = usePlatform()
const dialog = useDialog()
function handleShowChangelog() {
dialog.show(() => <DialogChangelog />)
}
return (
<Dialog size="x-large" transition>
@@ -47,11 +56,27 @@ export const DialogSettings: Component = () => {
</Tabs.Trigger>
</div>
</div>
<div class="flex flex-col gap-1.5">
<Tabs.SectionTitle>{language.t("settings.section.data")}</Tabs.SectionTitle>
<div class="flex flex-col gap-1.5 w-full">
<Tabs.Trigger value="archive">
<Icon name="archive" />
{language.t("settings.archive.title")}
</Tabs.Trigger>
</div>
</div>
</div>
</div>
<div class="flex flex-col gap-1 pl-1 py-1 text-12-medium text-text-weak">
<span>{language.t("app.name.desktop")}</span>
<span class="text-11-regular">v{platform.version}</span>
<button
class="text-11-regular text-text-weak hover:text-text-base self-start"
onClick={handleShowChangelog}
>
Changelog
</button>
</div>
</div>
</Tabs.List>
@@ -67,6 +92,9 @@ export const DialogSettings: Component = () => {
<Tabs.Content value="models" class="no-scrollbar">
<SettingsModels />
</Tabs.Content>
<Tabs.Content value="archive" class="no-scrollbar">
<SettingsArchive />
</Tabs.Content>
</Tabs>
</Dialog>
)

View File

@@ -325,12 +325,6 @@ export default function FileTree(props: {
),
)
createEffect(() => {
const dir = file.tree.state(props.path)
if (!shouldListExpanded({ level, dir })) return
void file.tree.list(props.path)
})
const nodes = createMemo(() => {
const nodes = file.tree.children(props.path)
const current = filter()

View File

@@ -1,4 +1,5 @@
import { useFilteredList } from "@opencode-ai/ui/hooks"
import { useSpring } from "@opencode-ai/ui/motion-spring"
import { createEffect, on, Component, Show, onCleanup, Switch, Match, createMemo, createSignal } from "solid-js"
import { createStore } from "solid-js/store"
import { createFocusSignal } from "@solid-primitives/active-element"
@@ -23,7 +24,6 @@ import { Button } from "@opencode-ai/ui/button"
import { DockShellForm, DockTray } from "@opencode-ai/ui/dock-surface"
import { Icon } from "@opencode-ai/ui/icon"
import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
import type { IconName } from "@opencode-ai/ui/icons/provider"
import { Tooltip, TooltipKeybind } from "@opencode-ai/ui/tooltip"
import { IconButton } from "@opencode-ai/ui/icon-button"
import { Select } from "@opencode-ai/ui/select"
@@ -244,6 +244,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
draggingType: "image" | "@mention" | null
mode: "normal" | "shell"
applyingHistory: boolean
pendingAutoAccept: boolean
}>({
popover: null,
historyIndex: -1,
@@ -252,6 +253,19 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
draggingType: null,
mode: "normal",
applyingHistory: false,
pendingAutoAccept: false,
})
const buttonsSpring = useSpring(
() => (store.mode === "normal" ? 1 : 0),
{ visualDuration: 0.2, bounce: 0 },
)
const springFade = (t: number): Record<string, string> => ({
opacity: `${t}`,
transform: `scale(${0.95 + t * 0.05})`,
filter: `blur(${(1 - t) * 2}px)`,
"pointer-events": t > 0.5 ? "auto" : "none",
})
const commentCount = createMemo(() => {
@@ -302,6 +316,12 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
}),
)
createEffect(
on(sessionKey, () => {
setStore("pendingAutoAccept", false)
}),
)
const historyComments = () => {
const byID = new Map(comments.all().map((item) => [`${item.file}\n${item.id}`, item] as const))
return prompt.context.items().flatMap((item) => {
@@ -592,7 +612,6 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
setActive: setSlashActive,
onInput: slashOnInput,
onKeyDown: slashOnKeyDown,
refetch: slashRefetch,
} = useFilteredList<SlashCommand>({
items: slashCommands,
key: (x) => x?.id,
@@ -649,14 +668,6 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
}
}
createEffect(
on(
() => sync.data.command,
() => slashRefetch(),
{ defer: true },
),
)
// Auto-scroll active command into view when navigating with keyboard
createEffect(() => {
const activeId = slashActive()
@@ -957,10 +968,18 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
readClipboardImage: platform.readClipboardImage,
})
const variants = createMemo(() => ["default", ...local.model.variant.list()])
const accepting = createMemo(() => {
const id = params.id
if (!id) return store.pendingAutoAccept
return permission.isAutoAccepting(id, sdk.directory)
})
const { abort, handleSubmit } = createPromptSubmit({
info,
imageAttachments,
commentCount,
autoAccept: () => accepting(),
mode: () => store.mode,
working,
editor: () => editorRef,
@@ -1125,13 +1144,6 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
}
}
const variants = createMemo(() => ["default", ...local.model.variant.list()])
const accepting = createMemo(() => {
const id = params.id
if (!id) return false
return permission.isAutoAccepting(id, sdk.directory)
})
return (
<div class="relative size-full _max-h-[320px] flex flex-col gap-0">
<PromptPopover
@@ -1251,11 +1263,8 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
<div
aria-hidden={store.mode !== "normal"}
class="flex items-center gap-1 transition-all duration-200 ease-out"
classList={{
"opacity-100 translate-y-0 scale-100 pointer-events-auto": store.mode === "normal",
"opacity-0 translate-y-2 scale-95 pointer-events-none": store.mode !== "normal",
}}
class="flex items-center gap-1"
style={{ "pointer-events": buttonsSpring() > 0.5 ? "auto" : "none" }}
>
<TooltipKeybind
placement="top"
@@ -1267,6 +1276,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
type="button"
variant="ghost"
class="size-8 p-0"
style={springFade(buttonsSpring())}
onClick={pick}
disabled={store.mode !== "normal"}
tabIndex={store.mode === "normal" ? undefined : -1}
@@ -1304,6 +1314,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
icon={working() ? "stop" : "arrow-up"}
variant="primary"
class="size-8"
style={springFade(buttonsSpring())}
aria-label={working() ? language.t("prompt.action.stop") : language.t("prompt.action.send")}
/>
</Tooltip>
@@ -1323,9 +1334,11 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
<Button
data-action="prompt-permissions"
variant="ghost"
disabled={!params.id}
onClick={() => {
if (!params.id) return
if (!params.id) {
setStore("pendingAutoAccept", (value) => !value)
return
}
permission.toggleAutoAccept(params.id, sdk.directory)
}}
classList={{
@@ -1354,14 +1367,15 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
<Show when={store.mode === "normal" || store.mode === "shell"}>
<DockTray attach="top">
<div class="px-1.75 pt-5.5 pb-2 flex items-center gap-2 min-w-0">
<div class="flex items-center gap-1.5 min-w-0 flex-1">
<Show when={store.mode === "shell"}>
<div class="h-7 flex items-center gap-1.5 max-w-[160px] min-w-0" style={{ padding: "0 4px 0 8px" }}>
<span class="truncate text-13-medium text-text-strong">{language.t("prompt.mode.shell")}</span>
<div class="size-4 shrink-0" />
</div>
</Show>
<Show when={store.mode === "normal"}>
<div class="flex items-center gap-1.5 min-w-0 flex-1 relative">
<div
class="h-7 flex items-center gap-1.5 max-w-[160px] min-w-0 absolute inset-y-0 left-0"
style={{ padding: "0 4px 0 8px", ...springFade(1 - buttonsSpring()) }}
>
<span class="truncate text-13-medium text-text-strong">{language.t("prompt.mode.shell")}</span>
<div class="size-4 shrink-0" />
</div>
<div class="flex items-center gap-1.5 min-w-0 flex-1">
<TooltipKeybind
placement="top"
gutter={4}
@@ -1375,7 +1389,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
onSelect={local.agent.set}
class="capitalize max-w-[160px]"
valueClass="truncate text-13-regular"
triggerStyle={{ height: "28px" }}
triggerStyle={{ height: "28px", ...springFade(buttonsSpring()) }}
variant="ghost"
/>
</TooltipKeybind>
@@ -1393,12 +1407,12 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
variant="ghost"
size="normal"
class="min-w-0 max-w-[320px] text-13-regular group"
style={{ height: "28px" }}
style={{ height: "28px", ...springFade(buttonsSpring()) }}
onClick={() => dialog.show(() => <DialogSelectModelUnpaid />)}
>
<Show when={local.model.current()?.provider?.id}>
<ProviderIcon
id={local.model.current()!.provider.id as IconName}
id={local.model.current()!.provider.id}
class="size-4 shrink-0 opacity-40 group-hover:opacity-100 transition-opacity duration-150"
style={{ "will-change": "opacity", transform: "translateZ(0)" }}
/>
@@ -1422,13 +1436,13 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
triggerProps={{
variant: "ghost",
size: "normal",
style: { height: "28px" },
style: { height: "28px", ...springFade(buttonsSpring()) },
class: "min-w-0 max-w-[320px] text-13-regular group",
}}
>
<Show when={local.model.current()?.provider?.id}>
<ProviderIcon
id={local.model.current()!.provider.id as IconName}
id={local.model.current()!.provider.id}
class="size-4 shrink-0 opacity-40 group-hover:opacity-100 transition-opacity duration-150"
style={{ "will-change": "opacity", transform: "translateZ(0)" }}
/>
@@ -1454,11 +1468,11 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
onSelect={(x) => local.model.variant.set(x === "default" ? undefined : x)}
class="capitalize max-w-[160px]"
valueClass="truncate text-13-regular"
triggerStyle={{ height: "28px" }}
triggerStyle={{ height: "28px", ...springFade(buttonsSpring()) }}
variant="ghost"
/>
</TooltipKeybind>
</Show>
</div>
</div>
<div class="shrink-0">
<RadioGroup

View File

@@ -5,6 +5,7 @@ let createPromptSubmit: typeof import("./submit").createPromptSubmit
const createdClients: string[] = []
const createdSessions: string[] = []
const enabledAutoAccept: Array<{ sessionID: string; directory: string }> = []
const sentShell: string[] = []
const syncedDirectories: string[] = []
@@ -69,6 +70,14 @@ beforeAll(async () => {
}),
}))
mock.module("@/context/permission", () => ({
usePermission: () => ({
enableAutoAccept(sessionID: string, directory: string) {
enabledAutoAccept.push({ sessionID, directory })
},
}),
}))
mock.module("@/context/prompt", () => ({
usePrompt: () => ({
current: () => promptValue,
@@ -145,6 +154,7 @@ beforeAll(async () => {
beforeEach(() => {
createdClients.length = 0
createdSessions.length = 0
enabledAutoAccept.length = 0
sentShell.length = 0
syncedDirectories.length = 0
selected = "/repo/worktree-a"
@@ -156,6 +166,7 @@ describe("prompt submit worktree selection", () => {
info: () => undefined,
imageAttachments: () => [],
commentCount: () => 0,
autoAccept: () => false,
mode: () => "shell",
working: () => false,
editor: () => undefined,
@@ -181,4 +192,31 @@ describe("prompt submit worktree selection", () => {
expect(sentShell).toEqual(["/repo/worktree-a", "/repo/worktree-b"])
expect(syncedDirectories).toEqual(["/repo/worktree-a", "/repo/worktree-b"])
})
test("applies auto-accept to newly created sessions", async () => {
const submit = createPromptSubmit({
info: () => undefined,
imageAttachments: () => [],
commentCount: () => 0,
autoAccept: () => true,
mode: () => "shell",
working: () => false,
editor: () => undefined,
queueScroll: () => undefined,
promptLength: (value) => value.reduce((sum, part) => sum + ("content" in part ? part.content.length : 0), 0),
addToHistory: () => undefined,
resetHistoryNavigation: () => undefined,
setMode: () => undefined,
setPopover: () => undefined,
newSessionWorktree: () => selected,
onNewSessionWorktreeReset: () => undefined,
onSubmit: () => undefined,
})
const event = { preventDefault: () => undefined } as unknown as Event
await submit.handleSubmit(event)
expect(enabledAutoAccept).toEqual([{ sessionID: "session-1", directory: "/repo/worktree-a" }])
})
})

View File

@@ -1,13 +1,15 @@
import type { Message } from "@opencode-ai/sdk/v2/client"
import { showToast } from "@opencode-ai/ui/toast"
import { base64Encode } from "@opencode-ai/util/encode"
import { errorMessage } from "@/pages/layout/helpers"
import { useNavigate, useParams } from "@solidjs/router"
import type { Accessor } from "solid-js"
import { batch, type Accessor } from "solid-js"
import type { FileSelection } from "@/context/file"
import { useGlobalSync } from "@/context/global-sync"
import { useLanguage } from "@/context/language"
import { useLayout } from "@/context/layout"
import { useLocal } from "@/context/local"
import { usePermission } from "@/context/permission"
import { type ImageAttachmentPart, type Prompt, usePrompt } from "@/context/prompt"
import { useSDK } from "@/context/sdk"
import { useSync } from "@/context/sync"
@@ -27,6 +29,7 @@ type PromptSubmitInput = {
info: Accessor<{ id: string } | undefined>
imageAttachments: Accessor<ImageAttachmentPart[]>
commentCount: Accessor<number>
autoAccept: Accessor<boolean>
mode: Accessor<"normal" | "shell">
working: Accessor<boolean>
editor: () => HTMLDivElement | undefined
@@ -56,19 +59,13 @@ export function createPromptSubmit(input: PromptSubmitInput) {
const sync = useSync()
const globalSync = useGlobalSync()
const local = useLocal()
const permission = usePermission()
const prompt = usePrompt()
const layout = useLayout()
const language = useLanguage()
const params = useParams()
const errorMessage = (err: unknown) => {
if (err && typeof err === "object" && "data" in err) {
const data = (err as { data?: { message?: string } }).data
if (data?.message) return data.message
}
if (err instanceof Error) return err.message
return language.t("common.requestFailed")
}
const toastError = (err: unknown) => errorMessage(err, language.t("common.requestFailed"))
const abort = async () => {
const sessionID = params.id
@@ -140,6 +137,7 @@ export function createPromptSubmit(input: PromptSubmitInput) {
const projectDirectory = sdk.directory
const isNewSession = !params.id
const shouldAutoAccept = isNewSession && input.autoAccept()
const worktreeSelection = input.newSessionWorktree?.() || "main"
let sessionDirectory = projectDirectory
@@ -153,7 +151,7 @@ export function createPromptSubmit(input: PromptSubmitInput) {
.catch((err) => {
showToast({
title: language.t("prompt.toast.worktreeCreateFailed.title"),
description: errorMessage(err),
description: toastError(err),
})
return undefined
})
@@ -192,11 +190,12 @@ export function createPromptSubmit(input: PromptSubmitInput) {
.catch((err) => {
showToast({
title: language.t("prompt.toast.sessionCreateFailed.title"),
description: errorMessage(err),
description: toastError(err),
})
return undefined
})
if (session) {
if (shouldAutoAccept) permission.enableAutoAccept(session.id, sessionDirectory)
layout.handoff.setTabs(base64Encode(sessionDirectory), session.id)
navigate(`/${base64Encode(sessionDirectory)}/session/${session.id}`)
}
@@ -249,7 +248,7 @@ export function createPromptSubmit(input: PromptSubmitInput) {
.catch((err) => {
showToast({
title: language.t("prompt.toast.shellSendFailed.title"),
description: errorMessage(err),
description: toastError(err),
})
restoreInput()
})
@@ -281,7 +280,7 @@ export function createPromptSubmit(input: PromptSubmitInput) {
.catch((err) => {
showToast({
title: language.t("prompt.toast.commandSendFailed.title"),
description: errorMessage(err),
description: toastError(err),
})
restoreInput()
})
@@ -327,9 +326,14 @@ export function createPromptSubmit(input: PromptSubmitInput) {
messageID,
})
removeCommentItems(commentItems)
clearInput()
addOptimisticMessage()
batch(() => {
removeCommentItems(commentItems)
clearInput()
if (sessionDirectory === projectDirectory) {
sync.set("session_status", session.id, { type: "busy" })
}
addOptimisticMessage()
})
const waitForWorktree = async () => {
const worktree = WorktreeState.get(sessionDirectory)
@@ -406,7 +410,7 @@ export function createPromptSubmit(input: PromptSubmitInput) {
}
showToast({
title: language.t("prompt.toast.promptSendFailed.title"),
description: errorMessage(err),
description: toastError(err),
})
removeOptimisticMessage()
restoreCommentItems(commentItems)

View File

@@ -0,0 +1,61 @@
import { Component } from "solid-js"
import { List } from "@opencode-ai/ui/list"
import { Markdown } from "@opencode-ai/ui/markdown"
import { Button } from "@opencode-ai/ui/button"
import { Tag } from "@opencode-ai/ui/tag"
import { useLanguage } from "@/context/language"
import { getRelativeTime } from "@/utils/time"
type Release = {
tag: string
body: string
date: string
}
interface ReleaseListProps {
releases: Release[]
hasMore: boolean
loadingMore: boolean
onLoadMore: () => void
}
export const ReleaseList: Component<ReleaseListProps> = (props) => {
const language = useLanguage()
return (
<List
items={props.releases}
key={(x) => x.tag}
search={false}
emptyMessage="No releases found"
loadingMessage={language.t("common.loading")}
class="flex-1 min-h-0 overflow-hidden flex flex-col [&_[data-slot=list-scroll]]:session-scroller [&_[data-slot=list-item]]:block [&_[data-slot=list-item]]:p-0 [&_[data-slot=list-item]]:border-0 [&_[data-slot=list-item]]:bg-transparent [&_[data-slot=list-item]]:text-left [&_[data-slot=list-item]]:cursor-default [&_[data-slot=list-item]]:hover:bg-transparent [&_[data-slot=list-item]]:focus:outline-none"
add={{
render: () =>
props.hasMore ? (
<div class="p-4 flex justify-center">
<Button variant="secondary" size="small" onClick={props.onLoadMore} loading={props.loadingMore}>
{language.t("common.loadMore")}
</Button>
</div>
) : null,
}}
>
{(item) => (
<div class="mb-8">
<div class="py-2 pr-3 pl-2 flex items-baseline gap-2 sticky top-0 z-10 bg-surface-raised-stronger-non-alpha">
<span class="text-[20px] font-semibold">{item.tag}</span>
<span class="text-xs text-text-weak">{item.date ? getRelativeTime(item.date, language.t) : ""}</span>
{item.tag === props.releases[0]?.tag && <Tag>{language.t("changelog.tag.latest")}</Tag>}
</div>
<div class="px-2 pb-2">
<Markdown
text={item.body}
class="prose prose-sm max-w-none text-text-base [&_h2]:border-b [&_h2]:border-border-weak-base [&_h2]:pb-1 [&_h2]:mt-8 [&_h2]:mb-3 [&_h2]:text-sm [&_h2]:font-medium [&_h2]:capitalize [&_h2:first-child]:mt-4 [&_a.external-link]:text-text-interactive-base [&_a.external-link]:font-medium"
/>
</div>
</div>
)}
</List>
)
}

View File

@@ -0,0 +1,81 @@
import { describe, expect, test } from "bun:test"
import type { Message } from "@opencode-ai/sdk/v2/client"
import { findAssistantMessages } from "@opencode-ai/ui/find-assistant-messages"
function user(id: string): Message {
return {
id,
role: "user",
sessionID: "session-1",
time: { created: 1 },
} as unknown as Message
}
function assistant(id: string, parentID: string): Message {
return {
id,
role: "assistant",
sessionID: "session-1",
parentID,
time: { created: 1 },
} as unknown as Message
}
describe("findAssistantMessages", () => {
test("normal ordering: assistant after user in array → found via forward scan", () => {
const messages = [user("u1"), assistant("a1", "u1")]
const result = findAssistantMessages(messages, 0, "u1")
expect(result).toHaveLength(1)
expect(result[0].id).toBe("a1")
})
test("clock skew: assistant before user in array → found via backward scan", () => {
// When client clock is ahead, user ID sorts after assistant ID,
// so assistant appears earlier in the ID-sorted message array
const messages = [assistant("a1", "u1"), user("u1")]
const result = findAssistantMessages(messages, 1, "u1")
expect(result).toHaveLength(1)
expect(result[0].id).toBe("a1")
})
test("no assistant messages → returns empty array", () => {
const messages = [user("u1"), user("u2")]
const result = findAssistantMessages(messages, 0, "u1")
expect(result).toHaveLength(0)
})
test("multiple assistant messages with matching parentID → all found", () => {
const messages = [user("u1"), assistant("a1", "u1"), assistant("a2", "u1")]
const result = findAssistantMessages(messages, 0, "u1")
expect(result).toHaveLength(2)
expect(result[0].id).toBe("a1")
expect(result[1].id).toBe("a2")
})
test("does not return assistant messages with different parentID", () => {
const messages = [user("u1"), assistant("a1", "u1"), assistant("a2", "other")]
const result = findAssistantMessages(messages, 0, "u1")
expect(result).toHaveLength(1)
expect(result[0].id).toBe("a1")
})
test("stops forward scan at next user message", () => {
const messages = [user("u1"), assistant("a1", "u1"), user("u2"), assistant("a2", "u1")]
const result = findAssistantMessages(messages, 0, "u1")
expect(result).toHaveLength(1)
expect(result[0].id).toBe("a1")
})
test("stops backward scan at previous user message", () => {
const messages = [assistant("a0", "u1"), user("u0"), assistant("a1", "u1"), user("u1")]
const result = findAssistantMessages(messages, 3, "u1")
expect(result).toHaveLength(1)
expect(result[0].id).toBe("a1")
})
test("invalid index returns empty array", () => {
const messages = [user("u1")]
expect(findAssistantMessages(messages, -1, "u1")).toHaveLength(0)
expect(findAssistantMessages(messages, 5, "u1")).toHaveLength(0)
})
})

View File

@@ -4,8 +4,7 @@ import { useParams } from "@solidjs/router"
import { useSync } from "@/context/sync"
import { useLayout } from "@/context/layout"
import { checksum } from "@opencode-ai/util/encode"
import { findLast } from "@opencode-ai/util/array"
import { same } from "@/utils/same"
import { findLast, same } from "@opencode-ai/util/array"
import { Icon } from "@opencode-ai/ui/icon"
import { Accordion } from "@opencode-ai/ui/accordion"
import { StickyAccordionHeader } from "@opencode-ai/ui/sticky-accordion-header"

View File

@@ -138,12 +138,12 @@ function useSessionShare(args: {
globalSDK: ReturnType<typeof useGlobalSDK>
currentSession: () =>
| {
id: string
share?: {
url?: string
}
}
| undefined
sessionID: () => string | undefined
projectDirectory: () => string
platform: ReturnType<typeof usePlatform>
}) {
@@ -167,11 +167,11 @@ function useSessionShare(args: {
})
const shareSession = () => {
const session = args.currentSession()
if (!session || state.share) return
const sessionID = args.sessionID()
if (!sessionID || state.share) return
setState("share", true)
args.globalSDK.client.session
.share({ sessionID: session.id, directory: args.projectDirectory() })
.share({ sessionID, directory: args.projectDirectory() })
.catch((error) => {
console.error("Failed to share session", error)
})
@@ -181,11 +181,11 @@ function useSessionShare(args: {
}
const unshareSession = () => {
const session = args.currentSession()
if (!session || state.unshare) return
const sessionID = args.sessionID()
if (!sessionID || state.unshare) return
setState("unshare", true)
args.globalSDK.client.session
.unshare({ sessionID: session.id, directory: args.projectDirectory() })
.unshare({ sessionID, directory: args.projectDirectory() })
.catch((error) => {
console.error("Failed to unshare session", error)
})
@@ -243,9 +243,9 @@ export function SessionHeader() {
})
const hotkey = createMemo(() => command.keybind("file.open"))
const currentSession = createMemo(() => sync.data.session.find((s) => s.id === params.id))
const currentSession = createMemo(() => (params.id ? sync.session.get(params.id) : undefined))
const shareEnabled = createMemo(() => sync.data.config.share !== "disabled")
const showShare = createMemo(() => shareEnabled() && !!currentSession())
const showShare = createMemo(() => shareEnabled() && !!params.id)
const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`)
const view = createMemo(() => layout.view(sessionKey))
const os = createMemo(() => detectOS(platform))
@@ -306,11 +306,10 @@ export function SessionHeader() {
const current = createMemo(() => options().find((o) => o.id === prefs.app) ?? options()[0])
const opening = createMemo(() => openRequest.app !== undefined)
createEffect(() => {
const value = prefs.app
if (options().some((o) => o.id === value)) return
setPrefs("app", options()[0]?.id ?? "finder")
})
const selectApp = (app: OpenApp) => {
if (!options().some((item) => item.id === app)) return
setPrefs("app", app)
}
const openDir = (app: OpenApp) => {
if (opening() || !canOpen() || !platform.openPath) return
@@ -347,6 +346,7 @@ export function SessionHeader() {
const share = useSessionShare({
globalSDK,
currentSession,
sessionID: () => params.id,
projectDirectory,
platform,
})
@@ -458,7 +458,7 @@ export function SessionHeader() {
value={current().id}
onChange={(value) => {
if (!OPEN_APPS.includes(value as OpenApp)) return
setPrefs("app", value as OpenApp)
selectApp(value as OpenApp)
}}
>
<For each={options()}>

View File

@@ -0,0 +1,188 @@
import { Button } from "@opencode-ai/ui/button"
import { Icon } from "@opencode-ai/ui/icon"
import { RadioGroup } from "@opencode-ai/ui/radio-group"
import { getFilename } from "@opencode-ai/util/path"
import { Component, For, Show, createMemo, createResource, createSignal } from "solid-js"
import { useParams } from "@solidjs/router"
import { useGlobalSDK } from "@/context/global-sdk"
import { useGlobalSync } from "@/context/global-sync"
import { useLanguage } from "@/context/language"
import { useLayout } from "@/context/layout"
import { getRelativeTime } from "@/utils/time"
import { decode64 } from "@/utils/base64"
import type { Session } from "@opencode-ai/sdk/v2/client"
import { SessionSkeleton } from "@/pages/layout/sidebar-items"
type FilterScope = "all" | "current"
type ScopeOption = { value: FilterScope; label: "settings.archive.scope.all" | "settings.archive.scope.current" }
const scopeOptions: ScopeOption[] = [
{ value: "all", label: "settings.archive.scope.all" },
{ value: "current", label: "settings.archive.scope.current" },
]
export const SettingsArchive: Component = () => {
const language = useLanguage()
const globalSDK = useGlobalSDK()
const globalSync = useGlobalSync()
const layout = useLayout()
const params = useParams()
const [removedIds, setRemovedIds] = createSignal<Set<string>>(new Set())
const projects = createMemo(() => globalSync.data.project)
const layoutProjects = createMemo(() => layout.projects.list())
const hasMultipleProjects = createMemo(() => projects().length > 1)
const homedir = createMemo(() => globalSync.data.path.home)
const defaultScope = () => (hasMultipleProjects() ? "current" : "all")
const [filterScope, setFilterScope] = createSignal<FilterScope>(defaultScope())
const currentDirectory = createMemo(() => decode64(params.dir) ?? "")
const currentProject = createMemo(() => {
const dir = currentDirectory()
if (!dir) return null
return layoutProjects().find((p) => p.worktree === dir || p.sandboxes?.includes(dir)) ?? null
})
const filteredProjects = createMemo(() => {
if (filterScope() === "current" && currentProject()) {
return [currentProject()!]
}
return layoutProjects()
})
const getSessionLabel = (session: Session) => {
const directory = session.directory
const home = homedir()
const path = home ? directory.replace(home, "~") : directory
if (filterScope() === "current" && currentProject()) {
const current = currentProject()
const kind =
current && directory === current.worktree
? language.t("workspace.type.local")
: language.t("workspace.type.sandbox")
const [store] = globalSync.child(directory, { bootstrap: false })
const name = store.vcs?.branch ?? getFilename(directory)
return `${kind} : ${name || path}`
}
return path
}
const [archivedSessions] = createResource(
() => ({ scope: filterScope(), projects: filteredProjects() }),
async ({ projects }) => {
const allSessions: Session[] = []
for (const project of projects) {
const directories = [project.worktree, ...(project.sandboxes ?? [])]
for (const directory of directories) {
const result = await globalSDK.client.experimental.session.list({ directory, archived: true })
const sessions = result.data ?? []
for (const session of sessions) {
allSessions.push(session)
}
}
}
return allSessions.sort((a, b) => (b.time?.updated ?? 0) - (a.time?.updated ?? 0))
},
{ initialValue: [] },
)
const displayedSessions = () => {
const sessions = archivedSessions() ?? []
const removed = removedIds()
return sessions.filter((s) => !removed.has(s.id))
}
const currentScopeOption = () => scopeOptions.find((o) => o.value === filterScope())
const unarchiveSession = async (session: Session) => {
setRemovedIds((prev) => new Set(prev).add(session.id))
await globalSDK.client.session.update({
directory: session.directory,
sessionID: session.id,
time: { archived: null as any },
})
}
const handleScopeChange = (option: ScopeOption | undefined) => {
if (!option) return
setRemovedIds(new Set<string>())
setFilterScope(option.value)
}
return (
<div class="flex flex-col h-full overflow-y-auto no-scrollbar px-4 pb-10 sm:px-10 sm:pb-10">
<div class="sticky top-0 z-10 bg-[linear-gradient(to_bottom,var(--surface-stronger-non-alpha)_calc(100%_-_24px),transparent)]">
<div class="flex flex-col gap-1 pt-6 pb-8 max-w-[720px]">
<h2 class="text-16-medium text-text-strong">{language.t("settings.archive.title")}</h2>
<p class="text-14-regular text-text-weak">{language.t("settings.archive.description")}</p>
</div>
</div>
<div class="flex flex-col gap-4 max-w-[720px]">
<Show when={hasMultipleProjects()}>
<RadioGroup
options={scopeOptions}
current={currentScopeOption() ?? undefined}
value={(o) => o.value}
size="small"
label={(o) => language.t(o.label)}
onSelect={handleScopeChange}
/>
</Show>
<Show
when={!archivedSessions.loading}
fallback={
<div class="min-h-[700px]">
<SessionSkeleton count={4} />
</div>
}
>
<Show
when={displayedSessions().length}
fallback={
<div class="min-h-[700px]">
<div class="text-14-regular text-text-weak">{language.t("settings.archive.none")}</div>
</div>
}
>
<div class="min-h-[700px] flex flex-col gap-2">
<For each={displayedSessions()}>
{(session) => (
<div class="flex items-center justify-between gap-4 px-3 py-1 rounded-md hover:bg-surface-raised-base-hover">
<div class="flex items-center gap-x-3 grow min-w-0">
<div class="flex items-center gap-2 min-w-0">
<span class="text-14-regular text-text-strong truncate">{session.title}</span>
<span class="text-14-regular text-text-weak truncate">{getSessionLabel(session)}</span>
</div>
</div>
<div class="flex items-center gap-4 shrink-0">
<Show when={session.time?.updated}>
{(updated) => (
<span class="text-12-regular text-text-weak whitespace-nowrap">
{getRelativeTime(new Date(updated()).toISOString(), language.t)}
</span>
)}
</Show>
<Button
size="normal"
variant="secondary"
onClick={() => unarchiveSession(session)}
>
{language.t("common.unarchive")}
</Button>
</div>
</div>
)}
</For>
</div>
</Show>
</Show>
</div>
</div>
)
}

View File

@@ -4,7 +4,6 @@ import { Switch } from "@opencode-ai/ui/switch"
import { Icon } from "@opencode-ai/ui/icon"
import { IconButton } from "@opencode-ai/ui/icon-button"
import { TextField } from "@opencode-ai/ui/text-field"
import type { IconName } from "@opencode-ai/ui/icons/provider"
import { type Component, For, Show } from "solid-js"
import { useLanguage } from "@/context/language"
import { useModels } from "@/context/models"
@@ -98,7 +97,7 @@ export const SettingsModels: Component = () => {
{(group) => (
<div class="flex flex-col gap-1">
<div class="flex items-center gap-2 pb-2">
<ProviderIcon id={group.category as IconName} class="size-5 shrink-0 icon-strong-base" />
<ProviderIcon id={group.category} class="size-5 shrink-0 icon-strong-base" />
<span class="text-14-medium text-text-strong">{group.items[0].provider.name}</span>
</div>
<div class="bg-surface-raised-base px-4 rounded-lg">

View File

@@ -3,7 +3,6 @@ import { useDialog } from "@opencode-ai/ui/context/dialog"
import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
import { Tag } from "@opencode-ai/ui/tag"
import { showToast } from "@opencode-ai/ui/toast"
import { iconNames, type IconName } from "@opencode-ai/ui/icons/provider"
import { popularProviders, useProviders } from "@/hooks/use-providers"
import { createMemo, type Component, For, Show } from "solid-js"
import { useLanguage } from "@/context/language"
@@ -33,11 +32,6 @@ export const SettingsProviders: Component = () => {
const globalSync = useGlobalSync()
const providers = useProviders()
const icon = (id: string): IconName => {
if (iconNames.includes(id as IconName)) return id as IconName
return "synthetic"
}
const connected = createMemo(() => {
return providers
.connected()
@@ -154,7 +148,7 @@ export const SettingsProviders: Component = () => {
{(item) => (
<div class="group flex flex-wrap items-center justify-between gap-4 min-h-16 py-3 border-b border-border-weak-base last:border-none">
<div class="flex items-center gap-3 min-w-0">
<ProviderIcon id={icon(item.id)} class="size-5 shrink-0 icon-strong-base" />
<ProviderIcon id={item.id} class="size-5 shrink-0 icon-strong-base" />
<span class="text-14-medium text-text-strong truncate">{item.name}</span>
<Tag>{type(item)}</Tag>
</div>
@@ -185,7 +179,7 @@ export const SettingsProviders: Component = () => {
<div class="flex flex-wrap items-center justify-between gap-4 min-h-16 py-3 border-b border-border-weak-base last:border-none">
<div class="flex flex-col min-w-0">
<div class="flex items-center gap-x-3">
<ProviderIcon id={icon(item.id)} class="size-5 shrink-0 icon-strong-base" />
<ProviderIcon id={item.id} class="size-5 shrink-0 icon-strong-base" />
<span class="text-14-medium text-text-strong">{item.name}</span>
<Show when={item.id === "opencode"}>
<span class="text-14-regular text-text-weak">
@@ -228,7 +222,7 @@ export const SettingsProviders: Component = () => {
>
<div class="flex flex-col min-w-0">
<div class="flex flex-wrap items-center gap-x-3 gap-y-1">
<ProviderIcon id={icon("synthetic")} class="size-5 shrink-0 icon-strong-base" />
<ProviderIcon id="synthetic" class="size-5 shrink-0 icon-strong-base" />
<span class="text-14-medium text-text-strong">{language.t("provider.custom.title")}</span>
<Tag>{language.t("settings.providers.tag.custom")}</Tag>
</div>

View File

@@ -202,29 +202,26 @@ export function StatusPopover() {
triggerAs={Button}
triggerProps={{
variant: "ghost",
class:
"rounded-md h-[24px] pr-3 pl-0.5 gap-2 border border-border-weak-base bg-surface-panel shadow-none data-[expanded]:bg-surface-base-active",
class: "titlebar-icon w-6 h-6 p-0 box-border",
"aria-label": language.t("status.popover.trigger"),
style: { scale: 1 },
}}
trigger={
<div class="flex items-center gap-0.5">
<div class="size-4 flex items-center justify-center">
<div
classList={{
"size-1.5 rounded-full": true,
"bg-icon-success-base": overallHealthy(),
"bg-icon-critical-base": !overallHealthy() && server.healthy() !== undefined,
"bg-border-weak-base": server.healthy() === undefined,
}}
/>
</div>
<span class="text-12-regular text-text-strong">{language.t("status.popover.trigger")}</span>
<div class="flex size-4 items-center justify-center">
<div
classList={{
"size-1.5 rounded-full": true,
"bg-icon-success-base": overallHealthy(),
"bg-icon-critical-base": !overallHealthy() && server.healthy() !== undefined,
"bg-border-weak-base": server.healthy() === undefined,
}}
/>
</div>
}
class="[&_[data-slot=popover-body]]:p-0 w-[360px] max-w-[calc(100vw-40px)] bg-transparent border-0 shadow-none rounded-xl"
gutter={4}
placement="bottom-end"
shift={-136}
shift={-168}
>
<div class="flex items-center gap-1 w-[360px] rounded-xl shadow-[var(--shadow-lg-border-base)]">
<Tabs

View File

@@ -1,7 +1,7 @@
import { type HexColor, resolveThemeVariant, useTheme, withAlpha } from "@opencode-ai/ui/theme"
import { showToast } from "@opencode-ai/ui/toast"
import type { FitAddon, Ghostty, Terminal as Term } from "ghostty-web"
import { type ComponentProps, createEffect, createSignal, onCleanup, onMount, splitProps } from "solid-js"
import { type ComponentProps, createEffect, createMemo, onCleanup, onMount, splitProps } from "solid-js"
import { SerializeAddon } from "@/addons/serialize"
import { matchKeybind, parseKeybind } from "@/context/command"
import { useLanguage } from "@/context/language"
@@ -18,7 +18,7 @@ const DEFAULT_TOGGLE_TERMINAL_KEYBIND = "ctrl+`"
export interface TerminalProps extends ComponentProps<"div"> {
pty: LocalPTY
onSubmit?: () => void
onCleanup?: (pty: LocalPTY) => void
onCleanup?: (pty: Partial<LocalPTY> & { id: string }) => void
onConnect?: () => void
onConnectError?: (error: unknown) => void
}
@@ -126,8 +126,8 @@ const persistTerminal = (input: {
term: Term | undefined
addon: SerializeAddon | undefined
cursor: number
pty: LocalPTY
onCleanup?: (pty: LocalPTY) => void
id: string
onCleanup?: (pty: Partial<LocalPTY> & { id: string }) => void
}) => {
if (!input.addon || !input.onCleanup || !input.term) return
const buffer = (() => {
@@ -140,7 +140,7 @@ const persistTerminal = (input: {
})()
input.onCleanup({
...input.pty,
id: input.id,
buffer,
cursor: input.cursor,
rows: input.term.rows,
@@ -158,6 +158,19 @@ export const Terminal = (props: TerminalProps) => {
const server = useServer()
let container!: HTMLDivElement
const [local, others] = splitProps(props, ["pty", "class", "classList", "onConnect", "onConnectError"])
const id = local.pty.id
const restore = typeof local.pty.buffer === "string" ? local.pty.buffer : ""
const restoreSize =
restore &&
typeof local.pty.cols === "number" &&
Number.isSafeInteger(local.pty.cols) &&
local.pty.cols > 0 &&
typeof local.pty.rows === "number" &&
Number.isSafeInteger(local.pty.rows) &&
local.pty.rows > 0
? { cols: local.pty.cols, rows: local.pty.rows }
: undefined
const scrollY = typeof local.pty.scrollY === "number" ? local.pty.scrollY : undefined
let ws: WebSocket | undefined
let term: Term | undefined
let ghostty: Ghostty
@@ -190,7 +203,7 @@ export const Terminal = (props: TerminalProps) => {
const pushSize = (cols: number, rows: number) => {
return sdk.client.pty
.update({
ptyID: local.pty.id,
ptyID: id,
size: { cols, rows },
})
.catch((err) => {
@@ -219,7 +232,7 @@ export const Terminal = (props: TerminalProps) => {
}
}
const [terminalColors, setTerminalColors] = createSignal<TerminalColors>(getTerminalColors())
const terminalColors = createMemo(getTerminalColors)
const scheduleFit = () => {
if (disposed) return
@@ -259,8 +272,7 @@ export const Terminal = (props: TerminalProps) => {
}
createEffect(() => {
const colors = getTerminalColors()
setTerminalColors(colors)
const colors = terminalColors()
if (!term) return
setOptionIfSupported(term, "theme", colors)
})
@@ -320,18 +332,6 @@ export const Terminal = (props: TerminalProps) => {
const mod = loaded.mod
const g = loaded.ghostty
const restore = typeof local.pty.buffer === "string" ? local.pty.buffer : ""
const restoreSize =
restore &&
typeof local.pty.cols === "number" &&
Number.isSafeInteger(local.pty.cols) &&
local.pty.cols > 0 &&
typeof local.pty.rows === "number" &&
Number.isSafeInteger(local.pty.rows) &&
local.pty.rows > 0
? { cols: local.pty.cols, rows: local.pty.rows }
: undefined
const t = new mod.Terminal({
cursorBlink: true,
cursorStyle: "bar",
@@ -428,14 +428,14 @@ export const Terminal = (props: TerminalProps) => {
await write(restore)
fit.fit()
scheduleSize(t.cols, t.rows)
if (typeof local.pty.scrollY === "number") t.scrollToLine(local.pty.scrollY)
if (scrollY !== undefined) t.scrollToLine(scrollY)
startResize()
} else {
fit.fit()
scheduleSize(t.cols, t.rows)
if (restore) {
await write(restore)
if (typeof local.pty.scrollY === "number") t.scrollToLine(local.pty.scrollY)
if (scrollY !== undefined) t.scrollToLine(scrollY)
}
startResize()
}
@@ -447,9 +447,9 @@ export const Terminal = (props: TerminalProps) => {
const once = { value: false }
let closing = false
const url = new URL(sdk.url + `/pty/${local.pty.id}/connect`)
const url = new URL(sdk.url + `/pty/${id}/connect`)
url.searchParams.set("directory", sdk.directory)
url.searchParams.set("cursor", String(start !== undefined ? start : local.pty.buffer ? -1 : 0))
url.searchParams.set("cursor", String(start !== undefined ? start : restore ? -1 : 0))
url.protocol = url.protocol === "https:" ? "wss:" : "ws:"
url.username = server.current?.http.username ?? ""
url.password = server.current?.http.password ?? ""
@@ -543,7 +543,7 @@ export const Terminal = (props: TerminalProps) => {
if (ws && ws.readyState !== WebSocket.CLOSED && ws.readyState !== WebSocket.CLOSING) ws.close(1000)
const finalize = () => {
persistTerminal({ term, addon: serializeAddon, cursor, pty: local.pty, onCleanup: props.onCleanup })
persistTerminal({ term, addon: serializeAddon, cursor, id, onCleanup: props.onCleanup })
cleanup()
}

View File

@@ -157,6 +157,7 @@ export function Titlebar() {
<header
class="h-10 shrink-0 bg-background-base relative grid grid-cols-[auto_minmax(0,1fr)_auto] items-center"
style={{ "min-height": minHeight() }}
data-tauri-drag-region
onMouseDown={drag}
onDblClick={maximize}
>
@@ -265,6 +266,9 @@ export function Titlebar() {
</div>
</div>
<div id="opencode-titlebar-left" class="flex items-center gap-3 min-w-0 px-2" />
<div class="bg-icon-interactive-base text-background-base font-medium px-2 rounded-sm uppercase font-mono">
BETA
</div>
</div>
<div class="min-w-0 flex items-center justify-center pointer-events-none">
@@ -276,6 +280,7 @@ export function Titlebar() {
"flex items-center min-w-0 justify-end": true,
"pr-2": !windows(),
}}
data-tauri-drag-region
onMouseDown={drag}
>
<div id="opencode-titlebar-right" class="flex items-center gap-1 shrink-0 justify-end" />

View File

@@ -4,6 +4,7 @@ import { createSimpleContext } from "@opencode-ai/ui/context"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { useLanguage } from "@/context/language"
import { useSettings } from "@/context/settings"
import { isEditableTarget } from "@/utils/dom"
import { Persist, persisted } from "@/utils/persist"
const IS_MAC = typeof navigator === "object" && /(Mac|iPod|iPhone|iPad)/.test(navigator.platform)
@@ -177,14 +178,6 @@ export function formatKeybind(config: string): string {
return IS_MAC ? parts.join("") : parts.join("+")
}
function isEditableTarget(target: EventTarget | null) {
if (!(target instanceof HTMLElement)) return false
if (target.isContentEditable) return true
if (target.closest("[contenteditable='true']")) return true
if (target.closest("input, textarea, select")) return true
return false
}
export const { use: useCommand, provider: CommandProvider } = createSimpleContext({
name: "Command",
init: () => {

View File

@@ -11,7 +11,6 @@ import { showToast } from "@opencode-ai/ui/toast"
import { getFilename } from "@opencode-ai/util/path"
import {
createContext,
createEffect,
getOwner,
Match,
onCleanup,
@@ -35,7 +34,6 @@ import { trimSessions } from "./global-sync/session-trim"
import type { ProjectMeta } from "./global-sync/types"
import { SESSION_RECENT_LIMIT } from "./global-sync/types"
import { sanitizeProject } from "./global-sync/utils"
import { usePlatform } from "./platform"
import { formatServerError } from "@/utils/server-errors"
type GlobalStore = {
@@ -54,7 +52,6 @@ type GlobalStore = {
function createGlobalSync() {
const globalSDK = useGlobalSDK()
const platform = usePlatform()
const language = useLanguage()
const owner = getOwner()
if (!owner) throw new Error("GlobalSync must be created within owner")
@@ -64,7 +61,7 @@ function createGlobalSync() {
const sessionLoads = new Map<string, Promise<void>>()
const sessionMeta = new Map<string, { limit: number }>()
const [projectCache, setProjectCache, , projectCacheReady] = persisted(
const [projectCache, setProjectCache, projectInit] = persisted(
Persist.global("globalSync.project", ["globalSync.project.v1"]),
createStore({ value: [] as Project[] }),
)
@@ -80,6 +77,57 @@ function createGlobalSync() {
reload: undefined,
})
let active = true
let projectWritten = false
onCleanup(() => {
active = false
})
const cacheProjects = () => {
setProjectCache(
"value",
untrack(() => globalStore.project.map(sanitizeProject)),
)
}
const setProjects = (next: Project[] | ((draft: Project[]) => void)) => {
projectWritten = true
if (typeof next === "function") {
setGlobalStore("project", produce(next))
cacheProjects()
return
}
setGlobalStore("project", next)
cacheProjects()
}
const setBootStore = ((...input: unknown[]) => {
if (input[0] === "project" && Array.isArray(input[1])) {
setProjects(input[1] as Project[])
return input[1]
}
return (setGlobalStore as (...args: unknown[]) => unknown)(...input)
}) as typeof setGlobalStore
const set = ((...input: unknown[]) => {
if (input[0] === "project" && (Array.isArray(input[1]) || typeof input[1] === "function")) {
setProjects(input[1] as Project[] | ((draft: Project[]) => void))
return input[1]
}
return (setGlobalStore as (...args: unknown[]) => unknown)(...input)
}) as typeof setGlobalStore
if (projectInit instanceof Promise) {
void projectInit.then(() => {
if (!active) return
if (projectWritten) return
const cached = projectCache.value
if (cached.length === 0) return
setGlobalStore("project", cached)
})
}
const setSessionTodo = (sessionID: string, todos: Todo[] | undefined) => {
if (!sessionID) return
if (!todos) {
@@ -127,30 +175,6 @@ function createGlobalSync() {
return sdk
}
createEffect(() => {
if (!projectCacheReady()) return
if (globalStore.project.length !== 0) return
const cached = projectCache.value
if (cached.length === 0) return
setGlobalStore("project", cached)
})
createEffect(() => {
if (!projectCacheReady()) return
const projects = globalStore.project
if (projects.length === 0) {
const cachedLength = untrack(() => projectCache.value.length)
if (cachedLength !== 0) return
}
setProjectCache("value", projects.map(sanitizeProject))
})
createEffect(() => {
if (globalStore.reload !== "complete") return
setGlobalStore("reload", undefined)
queue.refresh()
})
async function loadSessions(directory: string) {
const pending = sessionLoads.get(directory)
if (pending) return pending
@@ -259,13 +283,7 @@ function createGlobalSync() {
event,
project: globalStore.project,
refresh: queue.refresh,
setGlobalProject(next) {
if (typeof next === "function") {
setGlobalStore("project", produce(next))
return
}
setGlobalStore("project", next)
},
setGlobalProject: setProjects,
})
if (event.type === "server.connected" || event.type === "global.disposed") {
for (const directory of Object.keys(children.children)) {
@@ -316,7 +334,7 @@ function createGlobalSync() {
unknownError: language.t("error.chain.unknown"),
invalidConfigurationError: language.t("error.server.invalidConfiguration"),
formatMoreCount: (count) => language.t("common.moreCountSuffix", { count }),
setGlobalStore,
setGlobalStore: setBootStore,
})
}
@@ -340,7 +358,8 @@ function createGlobalSync() {
.update({ config })
.then(bootstrap)
.then(() => {
setGlobalStore("reload", "complete")
setGlobalStore("reload", undefined)
queue.refresh()
})
.catch((error) => {
setGlobalStore("reload", undefined)
@@ -350,7 +369,7 @@ function createGlobalSync() {
return {
data: globalStore,
set: setGlobalStore,
set,
get ready() {
return globalStore.ready
},

View File

@@ -1,4 +1,4 @@
import { createRoot, createEffect, getOwner, onCleanup, runWithOwner, type Accessor, type Owner } from "solid-js"
import { createRoot, getOwner, onCleanup, runWithOwner, type Owner } from "solid-js"
import { createStore, type SetStoreFunction, type Store } from "solid-js/store"
import { Persist, persisted } from "@/utils/persist"
import type { VcsInfo } from "@opencode-ai/sdk/v2/client"
@@ -131,8 +131,7 @@ export function createChildStoreManager(input: {
)
if (!vcs) throw new Error("Failed to create persisted cache")
const vcsStore = vcs[0]
const vcsReady = vcs[3]
vcsCache.set(directory, { store: vcsStore, setStore: vcs[1], ready: vcsReady })
vcsCache.set(directory, { store: vcsStore, setStore: vcs[1], ready: vcs[3] })
const meta = runWithOwner(input.owner, () =>
persisted(
@@ -154,10 +153,12 @@ export function createChildStoreManager(input: {
const init = () =>
createRoot((dispose) => {
const initialMeta = meta[0].value
const initialIcon = icon[0].value
const child = createStore<State>({
project: "",
projectMeta: meta[0].value,
icon: icon[0].value,
projectMeta: initialMeta,
icon: initialIcon,
provider: { all: [], connected: [], default: {} },
config: {},
path: { state: "", config: "", worktree: "", directory: "", home: "" },
@@ -181,16 +182,27 @@ export function createChildStoreManager(input: {
children[directory] = child
disposers.set(directory, dispose)
createEffect(() => {
if (!vcsReady()) return
const onPersistedInit = (init: Promise<string> | string | null, run: () => void) => {
if (!(init instanceof Promise)) return
void init.then(() => {
if (children[directory] !== child) return
run()
})
}
onPersistedInit(vcs[2], () => {
const cached = vcsStore.value
if (!cached?.branch) return
child[1]("vcs", (value) => value ?? cached)
})
createEffect(() => {
onPersistedInit(meta[2], () => {
if (child[0].projectMeta !== initialMeta) return
child[1]("projectMeta", meta[0].value)
})
createEffect(() => {
onPersistedInit(icon[2], () => {
if (child[0].icon !== initialIcon) return
child[1]("icon", icon[0].value)
})
})

View File

@@ -7,8 +7,10 @@ import { useServer } from "./server"
import { usePlatform } from "./platform"
import { Project } from "@opencode-ai/sdk/v2"
import { Persist, persisted, removePersisted } from "@/utils/persist"
import { same } from "@/utils/same"
import { decode64 } from "@/utils/base64"
import { same } from "@opencode-ai/util/array"
import { createScrollPersistence, type SessionScroll } from "./layout-scroll"
import { createPathHelpers } from "./file/path"
const AVATAR_COLOR_KEYS = ["pink", "mint", "orange", "purple", "cyan", "lime"] as const
const DEFAULT_PANEL_WIDTH = 344
@@ -96,6 +98,38 @@ function nextSessionTabsForOpen(current: SessionTabs | undefined, tab: string):
return { all, active: tab }
}
const sessionPath = (key: string) => {
const dir = key.split("/")[0]
if (!dir) return
const root = decode64(dir)
if (!root) return
return createPathHelpers(() => root)
}
const normalizeSessionTab = (path: ReturnType<typeof createPathHelpers> | undefined, tab: string) => {
if (!tab.startsWith("file://")) return tab
if (!path) return tab
return path.tab(tab)
}
const normalizeSessionTabList = (path: ReturnType<typeof createPathHelpers> | undefined, all: string[]) => {
const seen = new Set<string>()
return all.flatMap((tab) => {
const value = normalizeSessionTab(path, tab)
if (seen.has(value)) return []
seen.add(value)
return [value]
})
}
const normalizeStoredSessionTabs = (key: string, tabs: SessionTabs) => {
const path = sessionPath(key)
return {
all: normalizeSessionTabList(path, tabs.all),
active: tabs.active ? normalizeSessionTab(path, tabs.active) : tabs.active,
}
}
export const { use: useLayout, provider: LayoutProvider } = createSimpleContext({
name: "Layout",
init: () => {
@@ -147,12 +181,46 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
}
})()
if (migratedSidebar === sidebar && migratedReview === review && migratedFileTree === fileTree) return value
const sessionTabs = value.sessionTabs
const migratedSessionTabs = (() => {
if (!isRecord(sessionTabs)) return sessionTabs
let changed = false
const next = Object.fromEntries(
Object.entries(sessionTabs).map(([key, tabs]) => {
if (!isRecord(tabs) || !Array.isArray(tabs.all)) return [key, tabs]
const current = {
all: tabs.all.filter((tab): tab is string => typeof tab === "string"),
active: typeof tabs.active === "string" ? tabs.active : undefined,
}
const normalized = normalizeStoredSessionTabs(key, current)
if (current.all.length !== tabs.all.length) changed = true
if (!same(current.all, normalized.all) || current.active !== normalized.active) changed = true
if (tabs.active !== undefined && typeof tabs.active !== "string") changed = true
return [key, normalized]
}),
)
if (!changed) return sessionTabs
return next
})()
if (
migratedSidebar === sidebar &&
migratedReview === review &&
migratedFileTree === fileTree &&
migratedSessionTabs === sessionTabs
) {
return value
}
return {
...value,
sidebar: migratedSidebar,
review: migratedReview,
fileTree: migratedFileTree,
sessionTabs: migratedSessionTabs,
}
}
@@ -745,22 +813,26 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
},
tabs(sessionKey: string | Accessor<string>) {
const key = createSessionKeyReader(sessionKey, ensureKey)
const path = createMemo(() => sessionPath(key()))
const tabs = createMemo(() => store.sessionTabs[key()] ?? { all: [] })
const normalize = (tab: string) => normalizeSessionTab(path(), tab)
const normalizeAll = (all: string[]) => normalizeSessionTabList(path(), all)
return {
tabs,
active: createMemo(() => tabs().active),
all: createMemo(() => tabs().all.filter((tab) => tab !== "review")),
setActive(tab: string | undefined) {
const session = key()
const next = tab ? normalize(tab) : tab
if (!store.sessionTabs[session]) {
setStore("sessionTabs", session, { all: [], active: tab })
setStore("sessionTabs", session, { all: [], active: next })
} else {
setStore("sessionTabs", session, "active", tab)
setStore("sessionTabs", session, "active", next)
}
},
setAll(all: string[]) {
const session = key()
const next = all.filter((tab) => tab !== "review")
const next = normalizeAll(all).filter((tab) => tab !== "review")
if (!store.sessionTabs[session]) {
setStore("sessionTabs", session, { all: next, active: undefined })
} else {
@@ -769,7 +841,7 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
},
async open(tab: string) {
const session = key()
const next = nextSessionTabsForOpen(store.sessionTabs[session], tab)
const next = nextSessionTabsForOpen(store.sessionTabs[session], normalize(tab))
setStore("sessionTabs", session, next)
},
close(tab: string) {

View File

@@ -31,13 +31,13 @@ describe("autoRespondsPermission", () => {
expect(autoRespondsPermission({ root: true }, sessions, permission("child"), "/tmp/project")).toBe(true)
})
test("defaults to auto-accept when no lineage override exists", () => {
test("defaults to requiring approval when no lineage override exists", () => {
const sessions = [session({ id: "root" }), session({ id: "child", parentID: "root" }), session({ id: "other" })]
const autoAccept = {
other: true,
}
expect(autoRespondsPermission(autoAccept, sessions, permission("child"), "/tmp/project")).toBe(true)
expect(autoRespondsPermission(autoAccept, sessions, permission("child"), "/tmp/project")).toBe(false)
})
test("inherits a parent session's false override", () => {

View File

@@ -37,5 +37,5 @@ export function autoRespondsPermission(
const value = sessionLineage(session, permission.sessionID)
.map((id) => accepted(autoAccept, id, directory))
.find((item): item is boolean => item !== undefined)
return value ?? true
return value ?? false
}

View File

@@ -43,12 +43,11 @@ type OptimisticRemoveInput = {
export function applyOptimisticAdd(draft: OptimisticStore, input: OptimisticAddInput) {
const messages = draft.message[input.sessionID]
if (!messages) {
draft.message[input.sessionID] = [input.message]
}
if (messages) {
const result = Binary.search(messages, input.message.id, (m) => m.id)
messages.splice(result.index, 0, input.message)
} else {
draft.message[input.sessionID] = [input.message]
}
draft.part[input.message.id] = sortParts(input.parts)
}
@@ -105,7 +104,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
return globalSync.child(directory)
}
const absolute = (path: string) => (current()[0].path.directory + "/" + path).replace("//", "/")
const messagePageSize = 400
const messagePageSize = 200
const inflight = new Map<string, Promise<void>>()
const inflightDiff = new Map<string, Promise<void>>()
const inflightTodo = new Map<string, Promise<void>>()
@@ -122,20 +121,12 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
return undefined
}
const limitFor = (count: number) => {
if (count <= messagePageSize) return messagePageSize
return Math.ceil(count / messagePageSize) * messagePageSize
}
const fetchMessages = async (input: { client: typeof sdk.client; sessionID: string; limit: number }) => {
const messages = await retry(() =>
input.client.session.messages({ sessionID: input.sessionID, limit: input.limit }),
)
const items = (messages.data ?? []).filter((x) => !!x?.info?.id)
const session = items
.map((x) => x.info)
.filter((m) => !!m?.id)
.sort((a, b) => cmp(a.id, b.id))
const session = items.map((x) => x.info).sort((a, b) => cmp(a.id, b.id))
const part = items.map((message) => ({ id: message.info.id, part: sortParts(message.parts) }))
return {
session,
@@ -159,8 +150,8 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
.then((next) => {
batch(() => {
input.setStore("message", input.sessionID, reconcile(next.session, { key: "id" }))
for (const message of next.part) {
input.setStore("part", message.id, reconcile(message.part, { key: "id" }))
for (const p of next.part) {
input.setStore("part", p.id, p.part)
}
setMeta("limit", key, input.limit)
setMeta("complete", key, next.complete)
@@ -229,17 +220,9 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
const client = sdk.client
const [store, setStore] = globalSync.child(directory)
const key = keyFor(directory, sessionID)
const hasSession = (() => {
const match = Binary.search(store.session, sessionID, (s) => s.id)
return match.found
})()
const hasSession = Binary.search(store.session, sessionID, (s) => s.id).found
const hasMessages = store.message[sessionID] !== undefined
const hydrated = meta.limit[key] !== undefined
if (hasSession && hasMessages && hydrated) return
const count = store.message[sessionID]?.length ?? 0
const limit = hydrated ? (meta.limit[key] ?? messagePageSize) : limitFor(count)
const limit = meta.limit[key] ?? messagePageSize
const sessionReq = hasSession
? Promise.resolve()
@@ -259,16 +242,13 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
)
})
const messagesReq =
hasMessages && hydrated
? Promise.resolve()
: loadMessages({
directory,
client,
setStore,
sessionID,
limit,
})
const messagesReq = loadMessages({
directory,
client,
setStore,
sessionID,
limit,
})
return runInflight(inflight, key, () => Promise.all([sessionReq, messagesReq]).then(() => {}))
},
@@ -290,14 +270,14 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
const client = sdk.client
const [store, setStore] = globalSync.child(directory)
const existing = store.todo[sessionID]
const cached = globalSync.data.session_todo[sessionID]
if (existing !== undefined) {
if (globalSync.data.session_todo[sessionID] === undefined) {
if (cached === undefined) {
globalSync.todo.set(sessionID, existing)
}
return
}
const cached = globalSync.data.session_todo[sessionID]
if (cached !== undefined) {
setStore("todo", sessionID, reconcile(cached, { key: "id" }))
}
@@ -324,11 +304,12 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
const key = keyFor(sdk.directory, sessionID)
return meta.loading[key] ?? false
},
async loadMore(sessionID: string, count = messagePageSize) {
async loadMore(sessionID: string, count?: number) {
const directory = sdk.directory
const client = sdk.client
const [, setStore] = globalSync.child(directory)
const key = keyFor(directory, sessionID)
const step = count ?? messagePageSize
if (meta.loading[key]) return
if (meta.complete[key]) return
@@ -338,7 +319,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
client,
setStore,
sessionID,
limit: currentLimit + count,
limit: currentLimit + step,
})
},
},

View File

@@ -506,6 +506,10 @@ export const dict = {
"common.close": "إغلاق",
"common.edit": "تحرير",
"common.loadMore": "تحميل المزيد",
"common.changelog": "التغييرات",
"common.noReleasesFound": "لم يتم العثور على إصدارات",
"changelog.tag.latest": "الأحدث",
"common.key.esc": "ESC",
"sidebar.menu.toggle": "تبديل القائمة",
"sidebar.nav.projectsAndSessions": "المشاريع والجلسات",
@@ -734,6 +738,11 @@ export const dict = {
"workspace.reset.archived.one": "ستتم أرشفة جلسة واحدة.",
"workspace.reset.archived.many": "ستتم أرشفة {{count}} جلسات.",
"workspace.reset.note": "سيؤدي هذا إلى إعادة تعيين مساحة العمل لتتطابق مع الفرع الافتراضي.",
"settings.archive.title": "الجلسات المؤرشفة",
"settings.archive.description": "استعادة الجلسات المؤرشفة لجعلها مرئية في الشريط الجانبي.",
"settings.archive.none": "لا توجد جلسات مؤرشفة.",
"settings.archive.scope.all": "جميع المشاريع",
"settings.archive.scope.current": "المشروع الحالي",
"common.open": "فتح",
"dialog.releaseNotes.action.getStarted": "البدء",
"dialog.releaseNotes.action.next": "التالي",
@@ -748,4 +757,4 @@ export const dict = {
"common.time.daysAgo.short": "قبل {{count}} ي",
"settings.providers.connected.environmentDescription": "متصل من متغيرات البيئة الخاصة بك",
"settings.providers.custom.description": "أضف مزود متوافق مع OpenAI بواسطة عنوان URL الأساسي.",
}
}

View File

@@ -512,6 +512,9 @@ export const dict = {
"common.close": "Fechar",
"common.edit": "Editar",
"common.loadMore": "Carregar mais",
"common.changelog": "Novidades",
"common.noReleasesFound": "Nenhuma release encontrada",
"changelog.tag.latest": "Mais recente",
"common.key.esc": "ESC",
"sidebar.menu.toggle": "Alternar menu",
"sidebar.nav.projectsAndSessions": "Projetos e sessões",
@@ -742,6 +745,11 @@ export const dict = {
"workspace.reset.archived.one": "1 sessão será arquivada.",
"workspace.reset.archived.many": "{{count}} sessões serão arquivadas.",
"workspace.reset.note": "Isso redefinirá o espaço de trabalho para corresponder ao branch padrão.",
"settings.archive.title": "Sessões arquivadas",
"settings.archive.description": "Restaure sessões arquivadas para torná-las visíveis na barra lateral.",
"settings.archive.none": "Nenhuma sessão arquivada.",
"settings.archive.scope.all": "Todos os projetos",
"settings.archive.scope.current": "Projeto atual",
"common.open": "Abrir",
"dialog.releaseNotes.action.getStarted": "Começar",
"dialog.releaseNotes.action.next": "Próximo",

View File

@@ -572,6 +572,9 @@ export const dict = {
"common.close": "Zatvori",
"common.edit": "Uredi",
"common.loadMore": "Učitaj još",
"common.changelog": "Novosti",
"common.noReleasesFound": "Nema pronađenih verzija",
"changelog.tag.latest": "Najnovije",
"common.key.esc": "ESC",
"sidebar.menu.toggle": "Prikaži/sakrij meni",
@@ -819,6 +822,11 @@ export const dict = {
"workspace.reset.archived.one": "1 sesija će biti arhivirana.",
"workspace.reset.archived.many": "Biće arhivirano {{count}} sesija.",
"workspace.reset.note": "Ovo će resetovati radni prostor da odgovara podrazumijevanoj grani.",
"settings.archive.title": "Arhivirane sesije",
"settings.archive.description": "Vrati arhivirane sesije da bi bile vidljive u bočnoj traci.",
"settings.archive.none": "Nema arhiviranih sesija.",
"settings.archive.scope.all": "Svi projekti",
"settings.archive.scope.current": "Trenutni projekt",
"common.open": "Otvori",
"dialog.releaseNotes.action.getStarted": "Započni",
"dialog.releaseNotes.action.next": "Sljedeće",

View File

@@ -568,6 +568,9 @@ export const dict = {
"common.close": "Luk",
"common.edit": "Rediger",
"common.loadMore": "Indlæs flere",
"common.changelog": "Nyheder",
"common.noReleasesFound": "Ingen versioner fundet",
"changelog.tag.latest": "Seneste",
"common.key.esc": "ESC",
"sidebar.menu.toggle": "Skift menu",
@@ -813,6 +816,11 @@ export const dict = {
"workspace.reset.archived.one": "1 session vil blive arkiveret.",
"workspace.reset.archived.many": "{{count}} sessioner vil blive arkiveret.",
"workspace.reset.note": "Dette vil nulstille arbejdsområdet til at matche hovedgrenen.",
"settings.archive.title": "Arkiverede sessioner",
"settings.archive.description": "Gendan arkiverede sessioner for at gøre dem synlige i sidebjælken.",
"settings.archive.none": "Ingen arkiverede sessioner.",
"settings.archive.scope.all": "Alle projekter",
"settings.archive.scope.current": "Nuværende projekt",
"common.open": "Åbn",
"dialog.releaseNotes.action.getStarted": "Kom i gang",
"dialog.releaseNotes.action.next": "Næste",

View File

@@ -520,6 +520,10 @@ export const dict = {
"common.close": "Schließen",
"common.edit": "Bearbeiten",
"common.loadMore": "Mehr laden",
"common.changelog": "Neuerungen",
"common.noReleasesFound": "Keine Versionen gefunden",
"changelog.tag.latest": "Neueste",
"common.key.esc": "ESC",
"sidebar.menu.toggle": "Menü umschalten",
"sidebar.nav.projectsAndSessions": "Projekte und Sitzungen",
@@ -751,6 +755,12 @@ export const dict = {
"workspace.reset.archived.one": "1 Sitzung wird archiviert.",
"workspace.reset.archived.many": "{{count}} Sitzungen werden archiviert.",
"workspace.reset.note": "Dadurch wird der Arbeitsbereich auf den Standard-Branch zurückgesetzt.",
"settings.archive.title": "Archivierte Sitzungen",
"settings.archive.description": "Archivierte Sitzungen wiederherstellen, um sie in der Seitenleiste anzuzeigen.",
"settings.archive.none": "Keine archivierten Sitzungen.",
"settings.archive.scope.all": "Alle Projekte",
"settings.archive.scope.current": "Aktuelles Projekt",
"common.open": "Öffnen",
"dialog.releaseNotes.action.getStarted": "Loslegen",
"dialog.releaseNotes.action.next": "Weiter",

View File

@@ -585,16 +585,19 @@ export const dict = {
"common.rename": "Rename",
"common.reset": "Reset",
"common.archive": "Archive",
"common.unarchive": "Unarchive",
"common.delete": "Delete",
"common.close": "Close",
"common.edit": "Edit",
"common.loadMore": "Load more",
"common.key.esc": "ESC",
"common.changelog": "Changelog",
"common.noReleasesFound": "No releases found",
"common.time.justNow": "Just now",
"common.time.minutesAgo.short": "{{count}}m ago",
"common.time.hoursAgo.short": "{{count}}h ago",
"common.time.daysAgo.short": "{{count}}d ago",
"changelog.tag.latest": "Latest",
"common.key.esc": "ESC",
"sidebar.menu.toggle": "Toggle menu",
"sidebar.nav.projectsAndSessions": "Projects and sessions",
@@ -613,6 +616,7 @@ export const dict = {
"settings.section.desktop": "Desktop",
"settings.section.server": "Server",
"settings.section.data": "Data",
"settings.tab.general": "General",
"settings.tab.shortcuts": "Shortcuts",
"settings.desktop.section.wsl": "WSL",
@@ -844,4 +848,10 @@ export const dict = {
"workspace.reset.archived.one": "1 session will be archived.",
"workspace.reset.archived.many": "{{count}} sessions will be archived.",
"workspace.reset.note": "This will reset the workspace to match the default branch.",
"settings.archive.title": "Archived Sessions",
"settings.archive.description": "Restore archived sessions to make them visible in the sidebar.",
"settings.archive.none": "No archived sessions.",
"settings.archive.scope.all": "All projects",
"settings.archive.scope.current": "Current project",
}

View File

@@ -575,6 +575,10 @@ export const dict = {
"common.close": "Cerrar",
"common.edit": "Editar",
"common.loadMore": "Cargar más",
"common.changelog": "Novedades",
"common.noReleasesFound": "No se encontraron versiones",
"changelog.tag.latest": "Último",
"common.key.esc": "ESC",
"sidebar.menu.toggle": "Alternar menú",
@@ -825,6 +829,12 @@ export const dict = {
"workspace.reset.archived.one": "1 sesión será archivada.",
"workspace.reset.archived.many": "{{count}} sesiones serán archivadas.",
"workspace.reset.note": "Esto restablecerá el espacio de trabajo para coincidir con la rama predeterminada.",
"settings.archive.title": "Sesiones archivadas",
"settings.archive.description": "Restaura las sesiones archivadas para hacerlas visibles en la barra lateral.",
"settings.archive.none": "No hay sesiones archivadas.",
"settings.archive.scope.all": "Todos los proyectos",
"settings.archive.scope.current": "Proyecto actual",
"common.open": "Abrir",
"dialog.releaseNotes.action.getStarted": "Comenzar",
"dialog.releaseNotes.action.next": "Siguiente",

View File

@@ -516,6 +516,10 @@ export const dict = {
"common.close": "Fermer",
"common.edit": "Modifier",
"common.loadMore": "Charger plus",
"common.changelog": "Nouveautés",
"common.noReleasesFound": "Aucune version trouvée",
"changelog.tag.latest": "Dernier",
"common.key.esc": "ESC",
"sidebar.menu.toggle": "Basculer le menu",
"sidebar.nav.projectsAndSessions": "Projets et sessions",
@@ -749,6 +753,11 @@ export const dict = {
"workspace.reset.archived.one": "1 session sera archivée.",
"workspace.reset.archived.many": "{{count}} sessions seront archivées.",
"workspace.reset.note": "Cela réinitialisera l'espace de travail pour correspondre à la branche par défaut.",
"settings.archive.title": "Sessions archivées",
"settings.archive.description": "Restaurez les sessions archivées pour les rendre visibles dans la barre latérale.",
"settings.archive.none": "Aucune session archivée.",
"settings.archive.scope.all": "Tous les Projets",
"settings.archive.scope.current": "Projet actuel",
"common.open": "Ouvrir",
"dialog.releaseNotes.action.getStarted": "Commencer",
"dialog.releaseNotes.action.next": "Suivant",

View File

@@ -510,6 +510,10 @@ export const dict = {
"common.close": "閉じる",
"common.edit": "編集",
"common.loadMore": "さらに読み込む",
"common.changelog": "更新履歴",
"common.noReleasesFound": "バージョンが見つかりません",
"changelog.tag.latest": "最新",
"common.key.esc": "ESC",
"sidebar.menu.toggle": "メニューを切り替え",
"sidebar.nav.projectsAndSessions": "プロジェクトとセッション",
@@ -738,6 +742,12 @@ export const dict = {
"workspace.reset.archived.one": "1つのセッションがアーカイブされます。",
"workspace.reset.archived.many": "{{count}}個のセッションがアーカイブされます。",
"workspace.reset.note": "これにより、ワークスペースはデフォルトブランチと一致するようにリセットされます。",
"settings.archive.title": "アーカイブされたセッション",
"settings.archive.description": "アーカイブされたセッションを復元してサイドバーに表示します。",
"settings.archive.none": "アーカイブされたセッションはありません。",
"settings.archive.scope.all": "すべてのプロジェクト",
"settings.archive.scope.current": "現在のプロジェクト",
"common.open": "開く",
"dialog.releaseNotes.action.getStarted": "始める",
"dialog.releaseNotes.action.next": "次へ",

View File

@@ -511,6 +511,10 @@ export const dict = {
"common.close": "닫기",
"common.edit": "편집",
"common.loadMore": "더 불러오기",
"common.changelog": "새로운 기능",
"common.noReleasesFound": "버전을 찾을 수 없음",
"changelog.tag.latest": "최신",
"common.key.esc": "ESC",
"sidebar.menu.toggle": "메뉴 토글",
"sidebar.nav.projectsAndSessions": "프로젝트 및 세션",
@@ -738,6 +742,12 @@ export const dict = {
"workspace.reset.archived.one": "1개의 세션이 보관됩니다.",
"workspace.reset.archived.many": "{{count}}개의 세션이 보관됩니다.",
"workspace.reset.note": "이 작업은 작업 공간을 기본 브랜치와 일치하도록 재설정합니다.",
"settings.archive.title": "보관된 세션",
"settings.archive.description": "보관된 세션을 복원하여 사이드바에 표시합니다.",
"settings.archive.none": "보관된 세션이 없습니다.",
"settings.archive.scope.all": "모든 프로젝트",
"settings.archive.scope.current": "현재 프로젝트",
"common.open": "열기",
"dialog.releaseNotes.action.getStarted": "시작하기",
"dialog.releaseNotes.action.next": "다음",

View File

@@ -575,6 +575,9 @@ export const dict = {
"common.close": "Lukk",
"common.edit": "Rediger",
"common.loadMore": "Last flere",
"common.changelog": "Nyheter",
"common.noReleasesFound": "Ingen versjoner funnet",
"changelog.tag.latest": "Siste",
"common.key.esc": "ESC",
"sidebar.menu.toggle": "Veksle meny",
@@ -821,6 +824,12 @@ export const dict = {
"workspace.reset.archived.one": "1 sesjon vil bli arkivert.",
"workspace.reset.archived.many": "{{count}} sesjoner vil bli arkivert.",
"workspace.reset.note": "Dette vil tilbakestille arbeidsområdet til å samsvare med standardgrenen.",
"settings.archive.title": "Arkiverte økter",
"settings.archive.description": "Gjenopprett arkiverte økter for å gjøre dem synlige i sidefeltet.",
"settings.archive.none": "Ingen arkiverte økter.",
"settings.archive.scope.all": "Alle prosjekter",
"settings.archive.scope.current": "Nåværende prosjekt",
"common.open": "Åpne",
"dialog.releaseNotes.action.getStarted": "Kom i gang",
"dialog.releaseNotes.action.next": "Neste",

View File

@@ -511,6 +511,9 @@ export const dict = {
"common.close": "Zamknij",
"common.edit": "Edytuj",
"common.loadMore": "Załaduj więcej",
"common.changelog": "Nowości",
"common.noReleasesFound": "Nie znaleziono wersji",
"changelog.tag.latest": "Najnowszy",
"common.key.esc": "ESC",
"sidebar.menu.toggle": "Przełącz menu",
"sidebar.nav.projectsAndSessions": "Projekty i sesje",
@@ -740,6 +743,11 @@ export const dict = {
"workspace.reset.archived.one": "1 sesja zostanie zarchiwizowana.",
"workspace.reset.archived.many": "{{count}} sesji zostanie zarchiwizowanych.",
"workspace.reset.note": "To zresetuje przestrzeń roboczą, aby odpowiadała domyślnej gałęzi.",
"settings.archive.title": "Zarchiwizowane sesje",
"settings.archive.description": "Przywróć zarchiwizowane sesje, aby były widoczne na pasku bocznym.",
"settings.archive.none": "Brak zarchiwizowanych sesji.",
"settings.archive.scope.all": "Wszystkie projekty",
"settings.archive.scope.current": "Bieżący projekt",
"common.open": "Otwórz",
"dialog.releaseNotes.action.getStarted": "Rozpocznij",
"dialog.releaseNotes.action.next": "Dalej",

View File

@@ -573,6 +573,9 @@ export const dict = {
"common.close": "Закрыть",
"common.edit": "Редактировать",
"common.loadMore": "Загрузить ещё",
"common.changelog": "Что нового",
"common.noReleasesFound": "Версии не найдены",
"changelog.tag.latest": "Последний",
"common.key.esc": "ESC",
"sidebar.menu.toggle": "Переключить меню",
@@ -821,6 +824,11 @@ export const dict = {
"workspace.reset.archived.one": "1 сессия будет архивирована.",
"workspace.reset.archived.many": "{{count}} сессий будет архивировано.",
"workspace.reset.note": "Рабочее пространство будет сброшено в соответствие с веткой по умолчанию.",
"settings.archive.title": "Архивированные сессии",
"settings.archive.description": "Восстановите архивированные сессии, чтобы они отображались на боковой панели.",
"settings.archive.none": "Нет архивированных сессий.",
"settings.archive.scope.all": "Все проекты",
"settings.archive.scope.current": "Текущий проект",
"common.open": "Открыть",
"dialog.releaseNotes.action.getStarted": "Начать",
"dialog.releaseNotes.action.next": "Далее",

View File

@@ -567,6 +567,9 @@ export const dict = {
"common.close": "ปิด",
"common.edit": "แก้ไข",
"common.loadMore": "โหลดเพิ่มเติม",
"common.changelog": "อัปเดต",
"common.noReleasesFound": "ไม่พบเวอร์ชัน",
"changelog.tag.latest": "ล่าสุด",
"common.key.esc": "ESC",
"sidebar.menu.toggle": "สลับเมนู",
@@ -811,6 +814,12 @@ export const dict = {
"workspace.reset.archived.one": "1 เซสชันจะถูกจัดเก็บ",
"workspace.reset.archived.many": "{{count}} เซสชันจะถูกจัดเก็บ",
"workspace.reset.note": "สิ่งนี้จะรีเซ็ตพื้นที่ทำงานให้ตรงกับสาขาเริ่มต้น",
"settings.archive.title": "เซสชันที่จัดเก็บ",
"settings.archive.description": "กู้คืนเซสชันที่จัดเก็บเพื่อให้แสดงในแถบด้านข้าง",
"settings.archive.none": "ไม่มีเซสชันที่จัดเก็บ",
"settings.archive.scope.all": "โปรเจกต์ทั้งหมด",
"settings.archive.scope.current": "โปรเจกต์ปัจจุบัน",
"common.open": "เปิด",
"dialog.releaseNotes.action.getStarted": "เริ่มต้น",
"dialog.releaseNotes.action.next": "ถัดไป",

View File

@@ -566,6 +566,10 @@ export const dict = {
"common.close": "关闭",
"common.edit": "编辑",
"common.loadMore": "加载更多",
"common.changelog": "更新日志",
"common.noReleasesFound": "未找到版本",
"changelog.tag.latest": "最新",
"common.key.esc": "ESC",
"sidebar.menu.toggle": "切换菜单",
@@ -809,6 +813,12 @@ export const dict = {
"workspace.reset.archived.one": "将归档 1 个会话。",
"workspace.reset.archived.many": "将归档 {{count}} 个会话。",
"workspace.reset.note": "这将把工作区重置为与默认分支一致。",
"settings.archive.title": "归档会话",
"settings.archive.description": "恢复归档会话以使其在侧边栏中可见。",
"settings.archive.none": "没有归档会话。",
"settings.archive.scope.all": "所有项目",
"settings.archive.scope.current": "当前项目",
"common.open": "打开",
"dialog.releaseNotes.action.getStarted": "开始",
"dialog.releaseNotes.action.next": "下一步",

View File

@@ -563,6 +563,9 @@ export const dict = {
"common.close": "關閉",
"common.edit": "編輯",
"common.loadMore": "載入更多",
"common.changelog": "更新日誌",
"common.noReleasesFound": "未找到版本",
"changelog.tag.latest": "最新",
"common.key.esc": "ESC",
"sidebar.menu.toggle": "切換選單",
@@ -804,6 +807,12 @@ export const dict = {
"workspace.reset.archived.one": "將封存 1 個工作階段。",
"workspace.reset.archived.many": "將封存 {{count}} 個工作階段。",
"workspace.reset.note": "這將把工作區重設為與預設分支一致。",
"settings.archive.title": "封存工作階段",
"settings.archive.description": "恢復封存的工作階段以使其在側邊欄中可見。",
"settings.archive.none": "沒有封存的工作階段。",
"settings.archive.scope.all": "所有專案",
"settings.archive.scope.current": "目前專案",
"common.open": "打開",
"dialog.releaseNotes.action.getStarted": "開始",
"dialog.releaseNotes.action.next": "下一步",

View File

@@ -9,11 +9,13 @@ import { DataProvider } from "@opencode-ai/ui/context"
import { decode64 } from "@/utils/base64"
import { showToast } from "@opencode-ai/ui/toast"
import { useLanguage } from "@/context/language"
import { usePlatform } from "@/context/platform"
function DirectoryDataProvider(props: ParentProps<{ directory: string }>) {
const params = useParams()
const navigate = useNavigate()
const sync = useSync()
const platform = usePlatform()
return (
<DataProvider
@@ -21,6 +23,34 @@ function DirectoryDataProvider(props: ParentProps<{ directory: string }>) {
directory={props.directory}
onNavigateToSession={(sessionID: string) => navigate(`/${params.dir}/session/${sessionID}`)}
onSessionHref={(sessionID: string) => `/${params.dir}/session/${sessionID}`}
onOpenFilePath={async (input) => {
const file = input.path.replace(/^[\\/]+/, "")
const separator = props.directory.includes("\\") ? "\\" : "/"
const path = props.directory.endsWith(separator) ? props.directory + file : props.directory + separator + file
if (platform.platform === "desktop" && platform.openPath) {
await platform.openPath(path).catch((error) => {
const description = error instanceof Error ? error.message : String(error)
showToast({
variant: "error",
title: "Open failed",
description,
})
window.dispatchEvent(
new CustomEvent("opencode:open-file-path", {
detail: input,
}),
)
})
return
}
window.dispatchEvent(
new CustomEvent("opencode:open-file-path", {
detail: input,
}),
)
}}
>
<LocalProvider>{props.children}</LocalProvider>
</DataProvider>

View File

@@ -42,7 +42,9 @@ import { Binary } from "@opencode-ai/util/binary"
import { retry } from "@opencode-ai/util/retry"
import { playSound, soundSrc } from "@/utils/sound"
import { createAim } from "@/utils/aim"
import { setNavigate } from "@/utils/notification-click"
import { Worktree as WorktreeState } from "@/utils/worktree"
import { setSessionHandoff } from "@/pages/session/handoff"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { useTheme, type ColorScheme } from "@opencode-ai/ui/theme"
@@ -59,14 +61,19 @@ import { useLanguage, type Locale } from "@/context/language"
import {
childMapByParent,
displayName,
effectiveWorkspaceOrder,
errorMessage,
getDraggableId,
latestRootSession,
sortedRootSessions,
syncWorkspaceOrder,
workspaceKey,
} from "./layout/helpers"
import { collectOpenProjectDeepLinks, deepLinkEvent, drainPendingDeepLinks } from "./layout/deep-links"
import {
collectNewSessionDeepLinks,
collectOpenProjectDeepLinks,
deepLinkEvent,
drainPendingDeepLinks,
} from "./layout/deep-links"
import { createInlineEditorController } from "./layout/inline-editor"
import {
LocalWorkspace,
@@ -107,6 +114,7 @@ export default function Layout(props: ParentProps) {
const notification = useNotification()
const permission = usePermission()
const navigate = useNavigate()
setNavigate(navigate)
const providers = useProviders()
const dialog = useDialog()
const command = useCommand()
@@ -481,21 +489,6 @@ export default function Layout(props: ParentProps) {
return projects.find((p) => p.worktree === root)
})
createEffect(
on(
() => ({ ready: pageReady(), project: currentProject() }),
(value) => {
if (!value.ready) return
const project = value.project
if (!project) return
const last = server.projects.last()
if (last === project.worktree) return
server.projects.touch(project.worktree)
},
{ defer: true },
),
)
createEffect(
on(
() => ({ ready: pageReady(), layoutReady: layoutReady(), dir: params.dir, list: layout.projects.list() }),
@@ -554,29 +547,17 @@ export default function Layout(props: ParentProps) {
return layout.sidebar.workspaces(project.worktree)()
})
createEffect(() => {
if (!pageReady()) return
if (!layoutReady()) return
const visibleSessionDirs = createMemo(() => {
const project = currentProject()
if (!project) return
if (!project) return [] as string[]
if (!workspaceSetting()) return [project.worktree]
const local = project.worktree
const dirs = [project.worktree, ...(project.sandboxes ?? [])]
const existing = store.workspaceOrder[project.worktree]
const merged = syncWorkspaceOrder(local, dirs, existing)
if (!existing) {
setStore("workspaceOrder", project.worktree, merged)
return
}
if (merged.length !== existing.length) {
setStore("workspaceOrder", project.worktree, merged)
return
}
if (merged.some((d, i) => d !== existing[i])) {
setStore("workspaceOrder", project.worktree, merged)
}
const activeDir = currentDir()
return workspaceIds(project).filter((directory) => {
const expanded = store.workspaceExpanded[directory] ?? directory === project.worktree
const active = directory === activeDir
return expanded || active
})
})
createEffect(() => {
@@ -593,25 +574,17 @@ export default function Layout(props: ParentProps) {
})
const currentSessions = createMemo(() => {
const project = currentProject()
if (!project) return [] as Session[]
const now = Date.now()
if (workspaceSetting()) {
const dirs = workspaceIds(project)
const activeDir = currentDir()
const result: Session[] = []
for (const dir of dirs) {
const expanded = store.workspaceExpanded[dir] ?? dir === project.worktree
const active = dir === activeDir
if (!expanded && !active) continue
const [dirStore] = globalSync.child(dir, { bootstrap: true })
const dirSessions = sortedRootSessions(dirStore, now)
result.push(...dirSessions)
}
return result
const dirs = visibleSessionDirs()
if (dirs.length === 0) return [] as Session[]
const result: Session[] = []
for (const dir of dirs) {
const [dirStore] = globalSync.child(dir, { bootstrap: true })
const dirSessions = sortedRootSessions(dirStore, now)
result.push(...dirSessions)
}
const [projectStore] = globalSync.child(project.worktree)
return sortedRootSessions(projectStore, now)
return result
})
type PrefetchQueue = {
@@ -826,7 +799,6 @@ export default function Layout(props: ParentProps) {
}
navigateToSession(session)
queueMicrotask(() => scrollToSession(session.id, `${session.directory}:${session.id}`))
}
function navigateSessionByUnseen(offset: number) {
@@ -861,7 +833,6 @@ export default function Layout(props: ParentProps) {
}
navigateToSession(session)
queueMicrotask(() => scrollToSession(session.id, `${session.directory}:${session.id}`))
return
}
}
@@ -1094,34 +1065,90 @@ export default function Layout(props: ParentProps) {
return meta?.worktree ?? directory
}
function activeProjectRoot(directory: string) {
return currentProject()?.worktree ?? projectRoot(directory)
}
function touchProjectRoute() {
const root = currentProject()?.worktree
if (!root) return
if (server.projects.last() !== root) server.projects.touch(root)
return root
}
function rememberSessionRoute(directory: string, id: string, root = activeProjectRoot(directory)) {
setStore("lastProjectSession", root, { directory, id, at: Date.now() })
return root
}
function clearLastProjectSession(root: string) {
if (!store.lastProjectSession[root]) return
setStore(
"lastProjectSession",
produce((draft) => {
delete draft[root]
}),
)
}
function syncSessionRoute(directory: string, id: string, root = activeProjectRoot(directory)) {
rememberSessionRoute(directory, id, root)
notification.session.markViewed(id)
const expanded = untrack(() => store.workspaceExpanded[directory])
if (expanded === false) {
setStore("workspaceExpanded", directory, true)
}
requestAnimationFrame(() => scrollToSession(id, `${directory}:${id}`))
return root
}
async function navigateToProject(directory: string | undefined) {
if (!directory) return
const root = projectRoot(directory)
server.projects.touch(root)
const project = layout.projects.list().find((item) => item.worktree === root)
const dirs = Array.from(new Set([root, ...(store.workspaceOrder[root] ?? []), ...(project?.sandboxes ?? [])]))
let dirs = project
? effectiveWorkspaceOrder(root, [root, ...(project.sandboxes ?? [])], store.workspaceOrder[root])
: [root]
const canOpen = (value: string | undefined) => {
if (!value) return false
return dirs.some((item) => workspaceKey(item) === workspaceKey(value))
}
const refreshDirs = async (target?: string) => {
if (!target || target === root || canOpen(target)) return canOpen(target)
const listed = await globalSDK.client.worktree
.list({ directory: root })
.then((x) => x.data ?? [])
.catch(() => [] as string[])
dirs = effectiveWorkspaceOrder(root, [root, ...listed], store.workspaceOrder[root])
return canOpen(target)
}
const openSession = async (target: { directory: string; id: string }) => {
if (!canOpen(target.directory)) return false
const resolved = await globalSDK.client.session
.get({ sessionID: target.id })
.then((x) => x.data)
.catch(() => undefined)
const next = resolved?.directory ? resolved : target
setStore("lastProjectSession", root, { directory: next.directory, id: next.id, at: Date.now() })
navigateWithSidebarReset(`/${base64Encode(next.directory)}/session/${next.id}`)
if (!resolved?.directory) return false
if (!canOpen(resolved.directory)) return false
setStore("lastProjectSession", root, { directory: resolved.directory, id: resolved.id, at: Date.now() })
navigateWithSidebarReset(`/${base64Encode(resolved.directory)}/session/${resolved.id}`)
return true
}
const projectSession = store.lastProjectSession[root]
if (projectSession?.id) {
await openSession(projectSession)
return
await refreshDirs(projectSession.directory)
const opened = await openSession(projectSession)
if (opened) return
clearLastProjectSession(root)
}
const latest = latestRootSession(
dirs.map((item) => globalSync.child(item, { bootstrap: false })[0]),
Date.now(),
)
if (latest) {
await openSession(latest)
if (latest && (await openSession(latest))) {
return
}
@@ -1137,8 +1164,7 @@ export default function Layout(props: ParentProps) {
),
Date.now(),
)
if (fetched) {
await openSession(fetched)
if (fetched && (await openSession(fetched))) {
return
}
@@ -1157,9 +1183,20 @@ export default function Layout(props: ParentProps) {
const handleDeepLinks = (urls: string[]) => {
if (!server.isLocal()) return
for (const directory of collectOpenProjectDeepLinks(urls)) {
openProject(directory)
}
for (const link of collectNewSessionDeepLinks(urls)) {
openProject(link.directory, false)
const slug = base64Encode(link.directory)
if (link.prompt) {
setSessionHandoff(slug, { prompt: link.prompt })
}
const href = link.prompt ? `/${slug}/session?prompt=${encodeURIComponent(link.prompt)}` : `/${slug}/session`
navigateWithSidebarReset(href)
}
}
onMount(() => {
@@ -1195,11 +1232,28 @@ export default function Layout(props: ParentProps) {
}
function closeProject(directory: string) {
const index = layout.projects.list().findIndex((x) => x.worktree === directory)
const next = layout.projects.list()[index + 1]
const list = layout.projects.list()
const index = list.findIndex((x) => x.worktree === directory)
const active = currentProject()?.worktree === directory
if (index === -1) return
const next = list[index + 1]
if (!active) {
layout.projects.close(directory)
return
}
if (!next) {
layout.projects.close(directory)
navigate("/")
return
}
navigateWithSidebarReset(`/${base64Encode(next.worktree)}/session`)
layout.projects.close(directory)
if (next) navigateToProject(next.worktree)
else navigate("/")
queueMicrotask(() => {
void navigateToProject(next.worktree)
})
}
function toggleProjectWorkspaces(project: LocalProject) {
@@ -1240,9 +1294,17 @@ export default function Layout(props: ParentProps) {
}
}
const deleteWorkspace = async (root: string, directory: string) => {
const deleteWorkspace = async (root: string, directory: string, leaveDeletedWorkspace = false) => {
if (directory === root) return
const current = currentDir()
const currentKey = workspaceKey(current)
const deletedKey = workspaceKey(directory)
const shouldLeave = leaveDeletedWorkspace || (!!params.dir && currentKey === deletedKey)
if (!leaveDeletedWorkspace && shouldLeave) {
navigateWithSidebarReset(`/${base64Encode(root)}/session`)
}
setBusy(directory, true)
const result = await globalSDK.client.worktree
@@ -1260,6 +1322,10 @@ export default function Layout(props: ParentProps) {
if (!result) return
if (workspaceKey(store.lastProjectSession[root]?.directory ?? "") === workspaceKey(directory)) {
clearLastProjectSession(root)
}
globalSync.set(
"project",
produce((draft) => {
@@ -1273,8 +1339,18 @@ export default function Layout(props: ParentProps) {
layout.projects.close(directory)
layout.projects.open(root)
if (params.dir && currentDir() === directory) {
navigateToProject(root)
if (shouldLeave) return
const nextCurrent = currentDir()
const nextKey = workspaceKey(nextCurrent)
const project = layout.projects.list().find((item) => item.worktree === root)
const dirs = project
? effectiveWorkspaceOrder(root, [root, ...(project.sandboxes ?? [])], store.workspaceOrder[root])
: [root]
const valid = dirs.some((item) => workspaceKey(item) === nextKey)
if (params.dir && projectRoot(nextCurrent) === root && !valid) {
navigateWithSidebarReset(`/${base64Encode(root)}/session`)
}
}
@@ -1377,8 +1453,12 @@ export default function Layout(props: ParentProps) {
})
const handleDelete = () => {
const leaveDeletedWorkspace = !!params.dir && workspaceKey(currentDir()) === workspaceKey(props.directory)
if (leaveDeletedWorkspace) {
navigateWithSidebarReset(`/${base64Encode(props.root)}/session`)
}
dialog.close()
void deleteWorkspace(props.root, props.directory)
void deleteWorkspace(props.root, props.directory, leaveDeletedWorkspace)
}
const description = () => {
@@ -1486,26 +1566,42 @@ export default function Layout(props: ParentProps) {
)
}
const activeRoute = {
session: "",
sessionProject: "",
}
createEffect(
on(
() => ({ ready: pageReady(), dir: params.dir, id: params.id }),
(value) => {
if (!value.ready) return
const dir = value.dir
const id = value.id
if (!dir || !id) return
() => [pageReady(), params.dir, params.id, currentProject()?.worktree] as const,
([ready, dir, id]) => {
if (!ready || !dir) {
activeRoute.session = ""
activeRoute.sessionProject = ""
return
}
const directory = decode64(dir)
if (!directory) return
const at = Date.now()
setStore("lastProjectSession", projectRoot(directory), { directory, id, at })
notification.session.markViewed(id)
const expanded = untrack(() => store.workspaceExpanded[directory])
if (expanded === false) {
setStore("workspaceExpanded", directory, true)
const root = touchProjectRoute() ?? activeProjectRoot(directory)
if (!id) {
activeRoute.session = ""
activeRoute.sessionProject = ""
return
}
requestAnimationFrame(() => scrollToSession(id, `${directory}:${id}`))
const session = `${dir}/${id}`
if (session !== activeRoute.session) {
activeRoute.session = session
activeRoute.sessionProject = syncSessionRoute(directory, id, root)
return
}
if (root === activeRoute.sessionProject) return
activeRoute.sessionProject = rememberSessionRoute(directory, id, root)
},
{ defer: true },
),
)
@@ -1516,40 +1612,29 @@ export default function Layout(props: ParentProps) {
const loadedSessionDirs = new Set<string>()
createEffect(() => {
const project = currentProject()
const workspaces = workspaceSetting()
const next = new Set<string>()
if (!project) {
loadedSessionDirs.clear()
return
}
createEffect(
on(
visibleSessionDirs,
(dirs) => {
if (dirs.length === 0) {
loadedSessionDirs.clear()
return
}
if (workspaces) {
const activeDir = currentDir()
const dirs = [project.worktree, ...(project.sandboxes ?? [])]
for (const directory of dirs) {
const expanded = store.workspaceExpanded[directory] ?? directory === project.worktree
const active = directory === activeDir
if (!expanded && !active) continue
next.add(directory)
}
}
const next = new Set(dirs)
for (const directory of next) {
if (loadedSessionDirs.has(directory)) continue
globalSync.project.loadSessions(directory)
}
if (!workspaces) {
next.add(project.worktree)
}
for (const directory of next) {
if (loadedSessionDirs.has(directory)) continue
globalSync.project.loadSessions(directory)
}
loadedSessionDirs.clear()
for (const directory of next) {
loadedSessionDirs.add(directory)
}
})
loadedSessionDirs.clear()
for (const directory of next) {
loadedSessionDirs.add(directory)
}
},
{ defer: true },
),
)
function handleDragStart(event: unknown) {
const id = getDraggableId(event)
@@ -1583,14 +1668,11 @@ export default function Layout(props: ParentProps) {
const extra = directory && directory !== local && !dirs.includes(directory) ? directory : undefined
const pending = extra ? WorktreeState.get(extra)?.status === "pending" : false
const existing = store.workspaceOrder[project.worktree]
if (!existing) return extra ? [...dirs, extra] : dirs
const merged = syncWorkspaceOrder(local, dirs, existing)
if (pending && extra) return [local, extra, ...merged.filter((directory) => directory !== local)]
if (!extra) return merged
if (pending) return merged
return [...merged, extra]
const ordered = effectiveWorkspaceOrder(local, dirs, store.workspaceOrder[project.worktree])
if (pending && extra) return [local, extra, ...ordered.filter((item) => item !== local)]
if (!extra) return ordered
if (pending) return ordered
return [...ordered, extra]
}
const sidebarProject = createMemo(() => {
@@ -1623,7 +1705,11 @@ export default function Layout(props: ParentProps) {
const [item] = result.splice(fromIndex, 1)
if (!item) return
result.splice(toIndex, 0, item)
setStore("workspaceOrder", project.worktree, result)
setStore(
"workspaceOrder",
project.worktree,
result.filter((directory) => workspaceKey(directory) !== workspaceKey(project.worktree)),
)
}
function handleWorkspaceDragEnd() {
@@ -1661,10 +1747,9 @@ export default function Layout(props: ParentProps) {
const existing = prev ?? []
const next = existing.filter((item) => {
const id = workspaceKey(item)
if (id === root) return false
return id !== key
return id !== root && id !== key
})
return [local, created.directory, ...next]
return [created.directory, ...next]
})
globalSync.child(created.directory)
@@ -1851,45 +1936,34 @@ export default function Layout(props: ParentProps) {
when={workspacesEnabled()}
fallback={
<>
<div class="shrink-0 py-4 px-3">
<TooltipKeybind
title={language.t("command.session.new")}
keybind={command.keybind("session.new")}
placement="top"
>
<Button
size="large"
icon="plus-small"
class="w-full"
onClick={() => navigateWithSidebarReset(`/${base64Encode(p().worktree)}/session`)}
>
{language.t("command.session.new")}
</Button>
</TooltipKeybind>
</div>
<div class="flex-1 min-h-0">
<LocalWorkspace
ctx={workspaceSidebarCtx}
project={p()}
sortNow={sortNow}
mobile={panelProps.mobile}
header={
<TooltipKeybind
title={language.t("command.session.new")}
keybind={command.keybind("session.new")}
placement="top"
>
<Button
size="large"
icon="plus-small"
class="w-full"
onClick={() => navigateWithSidebarReset(`/${base64Encode(p().worktree)}/session`)}
>
{language.t("command.session.new")}
</Button>
</TooltipKeybind>
}
/>
</div>
</>
}
>
<>
<div class="shrink-0 py-4 px-3">
<TooltipKeybind
title={language.t("workspace.new")}
keybind={command.keybind("workspace.new")}
placement="top"
>
<Button size="large" icon="plus-small" class="w-full" onClick={() => createWorkspace(p())}>
{language.t("workspace.new")}
</Button>
</TooltipKeybind>
</div>
<div class="relative flex-1 min-h-0">
<DragDropProvider
onDragStart={handleWorkspaceDragStart}
@@ -1903,21 +1977,41 @@ export default function Layout(props: ParentProps) {
ref={(el) => {
if (!panelProps.mobile) scrollContainerRef = el
}}
class="size-full flex flex-col py-2 gap-4 overflow-y-auto no-scrollbar [overflow-anchor:none]"
class="size-full flex flex-col overflow-y-auto no-scrollbar [overflow-anchor:none]"
>
<SortableProvider ids={workspaces()}>
<For each={workspaces()}>
{(directory) => (
<SortableWorkspace
ctx={workspaceSidebarCtx}
directory={directory}
project={p()}
sortNow={sortNow}
mobile={panelProps.mobile}
/>
)}
</For>
</SortableProvider>
<div class="sticky top-0 z-20 pointer-events-none bg-[linear-gradient(to_bottom,var(--background-stronger)_calc(100%_-_24px),transparent)] pt-4 pb-6 px-3">
<div class="pointer-events-auto">
<TooltipKeybind
title={language.t("workspace.new")}
keybind={command.keybind("workspace.new")}
placement="top"
>
<Button
size="large"
icon="plus-small"
class="w-full"
onClick={() => createWorkspace(p())}
>
{language.t("workspace.new")}
</Button>
</TooltipKeybind>
</div>
</div>
<div class="flex flex-col gap-4 py-2">
<SortableProvider ids={workspaces()}>
<For each={workspaces()}>
{(directory) => (
<SortableWorkspace
ctx={workspaceSidebarCtx}
directory={directory}
project={p()}
sortNow={sortNow}
mobile={panelProps.mobile}
/>
)}
</For>
</SortableProvider>
</div>
</div>
<DragOverlay>
<WorkspaceDragOverlay
@@ -2015,7 +2109,11 @@ export default function Layout(props: ParentProps) {
onOpenSettings={openSettings}
helpLabel={() => language.t("sidebar.help")}
onOpenHelp={() => platform.openLink("https://opencode.ai/desktop-feedback")}
renderPanel={() => <SidebarPanel project={currentProject()} />}
renderPanel={() => (
<Show when={currentProject()} keyed>
{(project) => <SidebarPanel project={project} />}
</Show>
)}
/>
</div>
<Show when={!layout.sidebar.opened() ? hoverProjectData()?.worktree : undefined} keyed>

View File

@@ -1,15 +1,17 @@
export const deepLinkEvent = "opencode:deep-link"
export const parseDeepLink = (input: string) => {
const parseUrl = (input: string) => {
if (!input.startsWith("opencode://")) return
if (typeof URL.canParse === "function" && !URL.canParse(input)) return
const url = (() => {
try {
return new URL(input)
} catch {
return undefined
}
})()
try {
return new URL(input)
} catch {
return
}
}
export const parseDeepLink = (input: string) => {
const url = parseUrl(input)
if (!url) return
if (url.hostname !== "open-project") return
const directory = url.searchParams.get("directory")
@@ -17,9 +19,23 @@ export const parseDeepLink = (input: string) => {
return directory
}
export const parseNewSessionDeepLink = (input: string) => {
const url = parseUrl(input)
if (!url) return
if (url.hostname !== "new-session") return
const directory = url.searchParams.get("directory")
if (!directory) return
const prompt = url.searchParams.get("prompt") || undefined
if (!prompt) return { directory }
return { directory, prompt }
}
export const collectOpenProjectDeepLinks = (urls: string[]) =>
urls.map(parseDeepLink).filter((directory): directory is string => !!directory)
export const collectNewSessionDeepLinks = (urls: string[]) =>
urls.map(parseNewSessionDeepLink).filter((link): link is { directory: string; prompt?: string } => !!link)
type OpenCodeWindow = Window & {
__OPENCODE__?: {
deepLinks?: string[]

View File

@@ -1,15 +1,14 @@
import { describe, expect, test } from "bun:test"
import { type Session } from "@opencode-ai/sdk/v2/client"
import { collectOpenProjectDeepLinks, drainPendingDeepLinks, parseDeepLink } from "./deep-links"
import {
displayName,
errorMessage,
getDraggableId,
hasProjectPermissions,
latestRootSession,
syncWorkspaceOrder,
workspaceKey,
} from "./helpers"
collectNewSessionDeepLinks,
collectOpenProjectDeepLinks,
drainPendingDeepLinks,
parseDeepLink,
parseNewSessionDeepLink,
} from "./deep-links"
import { displayName, errorMessage, getDraggableId, syncWorkspaceOrder, workspaceKey } from "./helpers"
import { type Session } from "@opencode-ai/sdk/v2/client"
import { hasProjectPermissions, latestRootSession } from "./helpers"
const session = (input: Partial<Session> & Pick<Session, "id" | "directory">) =>
({
@@ -62,6 +61,28 @@ describe("layout deep links", () => {
expect(result).toEqual(["/a", "/c"])
})
test("parses new-session deep links with optional prompt", () => {
expect(parseNewSessionDeepLink("opencode://new-session?directory=/tmp/demo")).toEqual({ directory: "/tmp/demo" })
expect(parseNewSessionDeepLink("opencode://new-session?directory=/tmp/demo&prompt=hello%20world")).toEqual({
directory: "/tmp/demo",
prompt: "hello world",
})
})
test("ignores new-session deep links without directory", () => {
expect(parseNewSessionDeepLink("opencode://new-session")).toBeUndefined()
expect(parseNewSessionDeepLink("opencode://new-session?directory=")).toBeUndefined()
})
test("collects only valid new-session deep links", () => {
const result = collectNewSessionDeepLinks([
"opencode://new-session?directory=/a",
"opencode://open-project?directory=/b",
"opencode://new-session?directory=/c&prompt=ship%20it",
])
expect(result).toEqual([{ directory: "/a" }, { directory: "/c", prompt: "ship it" }])
})
test("drains global deep links once", () => {
const target = {
__OPENCODE__: {

View File

@@ -74,9 +74,29 @@ export const errorMessage = (err: unknown, fallback: string) => {
return fallback
}
export const syncWorkspaceOrder = (local: string, dirs: string[], existing?: string[]) => {
if (!existing) return dirs
const keep = existing.filter((d) => d !== local && dirs.includes(d))
const missing = dirs.filter((d) => d !== local && !existing.includes(d))
return [local, ...missing, ...keep]
export const effectiveWorkspaceOrder = (local: string, dirs: string[], persisted?: string[]) => {
const root = workspaceKey(local)
const live = new Map<string, string>()
for (const dir of dirs) {
const key = workspaceKey(dir)
if (key === root) continue
if (!live.has(key)) live.set(key, dir)
}
if (!persisted?.length) return [local, ...live.values()]
const result = [local]
for (const dir of persisted) {
const key = workspaceKey(dir)
if (key === root) continue
const match = live.get(key)
if (!match) continue
result.push(match)
live.delete(key)
}
return [...result, ...live.values()]
}
export const syncWorkspaceOrder = effectiveWorkspaceOrder

View File

@@ -6,7 +6,6 @@ import { useNotification } from "@/context/notification"
import { usePermission } from "@/context/permission"
import { base64Encode } from "@opencode-ai/util/encode"
import { Avatar } from "@opencode-ai/ui/avatar"
import { DiffChanges } from "@opencode-ai/ui/diff-changes"
import { HoverCard } from "@opencode-ai/ui/hover-card"
import { Icon } from "@opencode-ai/ui/icon"
import { IconButton } from "@opencode-ai/ui/icon-button"
@@ -137,13 +136,6 @@ const SessionRow = (props: {
<span class="text-14-regular text-text-strong grow-1 min-w-0 overflow-hidden text-ellipsis truncate">
{props.session.title}
</span>
<Show when={props.session.summary}>
{(summary) => (
<div class="group-hover/session:hidden group-active/session:hidden group-focus-within/session:hidden">
<DiffChanges changes={summary()} />
</div>
)}
</Show>
</div>
</A>
)
@@ -283,7 +275,7 @@ export const SessionItem = (props: SessionItemProps): JSX.Element => {
return (
<div
data-session-id={props.session.id}
class="group/session relative w-full rounded-md cursor-default transition-colors pl-2 pr-3
class="group/session relative w-full rounded-md cursor-default transition-colors pl-2 pr-3 scroll-mt-24
hover:bg-surface-raised-base-hover [&:has(:focus-visible)]:bg-surface-raised-base-hover has-[[data-expanded]]:bg-surface-raised-base-hover has-[.active]:bg-surface-base-active"
>
<Show

View File

@@ -467,6 +467,7 @@ export const LocalWorkspace = (props: {
project: LocalProject
sortNow: Accessor<number>
mobile?: boolean
header?: JSX.Element
}): JSX.Element => {
const globalSync = useGlobalSync()
const language = useLanguage()
@@ -488,9 +489,14 @@ export const LocalWorkspace = (props: {
return (
<div
ref={(el) => props.ctx.setScrollContainerRef(el, props.mobile)}
class="size-full flex flex-col py-2 overflow-y-auto no-scrollbar [overflow-anchor:none]"
class="size-full flex flex-col overflow-y-auto no-scrollbar [overflow-anchor:none]"
>
<nav class="flex flex-col gap-1 px-2">
<Show when={props.header}>
<div class="sticky top-0 z-20 pointer-events-none bg-[linear-gradient(to_bottom,var(--background-stronger)_calc(100%_-_24px),transparent)] pt-4 pb-6 px-3">
<div class="pointer-events-auto">{props.header}</div>
</div>
</Show>
<nav class="flex flex-col gap-1 px-2 pb-2" classList={{ "pt-2": !props.header }}>
<Show when={loading()}>
<SessionSkeleton />
</Show>

View File

@@ -1,37 +1,259 @@
import { onCleanup, Show, Match, Switch, createMemo, createEffect, on, onMount } from "solid-js"
import { createMediaQuery } from "@solid-primitives/media"
import { createResizeObserver } from "@solid-primitives/resize-observer"
import { useLocal } from "@/context/local"
import { selectionFromLines, useFile, type FileSelection, type SelectedLineRange } from "@/context/file"
import { createStore } from "solid-js/store"
import { ResizeHandle } from "@opencode-ai/ui/resize-handle"
import { Select } from "@opencode-ai/ui/select"
import type { UserMessage } from "@opencode-ai/sdk/v2"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { createAutoScroll } from "@opencode-ai/ui/hooks"
import { Mark } from "@opencode-ai/ui/logo"
import { useSync } from "@/context/sync"
import { useLayout } from "@/context/layout"
import { checksum, base64Encode } from "@opencode-ai/util/encode"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { useLanguage } from "@/context/language"
import { useNavigate, useParams } from "@solidjs/router"
import { UserMessage } from "@opencode-ai/sdk/v2"
import { useSDK } from "@/context/sdk"
import { usePrompt } from "@/context/prompt"
import { ResizeHandle } from "@opencode-ai/ui/resize-handle"
import { Select } from "@opencode-ai/ui/select"
import { base64Encode, checksum } from "@opencode-ai/util/encode"
import { createMediaQuery } from "@solid-primitives/media"
import { createResizeObserver } from "@solid-primitives/resize-observer"
import { useNavigate, useParams, useSearchParams } from "@solidjs/router"
import {
createComputed,
createEffect,
createMemo,
Match,
on,
onCleanup,
onMount,
Show,
Switch,
untrack,
} from "solid-js"
import { createStore } from "solid-js/store"
import { NewSessionView, SessionHeader } from "@/components/session"
import { useComments } from "@/context/comments"
import { SessionHeader, NewSessionView } from "@/components/session"
import { same } from "@/utils/same"
import { type FileSelection, type SelectedLineRange, selectionFromLines, useFile } from "@/context/file"
import { useLanguage } from "@/context/language"
import { useLayout } from "@/context/layout"
import { useLocal } from "@/context/local"
import { usePrompt } from "@/context/prompt"
import { useSDK } from "@/context/sdk"
import { useSync } from "@/context/sync"
import { createSessionComposerState, SessionComposerRegion } from "@/pages/session/composer"
import { same } from "@opencode-ai/util/array"
import { isEditableTarget } from "@/utils/dom"
import { createOpenReviewFile } from "@/pages/session/helpers"
import { createScrollSpy } from "@/pages/session/scroll-spy"
import { SessionReviewTab, type DiffStyle, type SessionReviewTabProps } from "@/pages/session/review-tab"
import { TerminalPanel } from "@/pages/session/terminal-panel"
import { MessageTimeline } from "@/pages/session/message-timeline"
import { type DiffStyle, SessionReviewTab, type SessionReviewTabProps } from "@/pages/session/review-tab"
import { createScrollSpy } from "@/pages/session/scroll-spy"
import { AnimationDebugPanel } from "@opencode-ai/ui/animation-debug-panel"
import { useSessionCommands } from "@/pages/session/use-session-commands"
import { SessionComposerRegion, createSessionComposerState } from "@/pages/session/composer"
import { SessionMobileTabs } from "@/pages/session/session-mobile-tabs"
import { SessionSidePanel } from "@/pages/session/session-side-panel"
import { TerminalPanel } from "@/pages/session/terminal-panel"
import { useSessionHashScroll } from "@/pages/session/use-session-hash-scroll"
const emptyUserMessages: UserMessage[] = []
type SessionHistoryWindowInput = {
sessionID: () => string | undefined
messagesReady: () => boolean
visibleUserMessages: () => UserMessage[]
historyMore: () => boolean
historyLoading: () => boolean
loadMore: (sessionID: string) => Promise<void>
userScrolled: () => boolean
scroller: () => HTMLDivElement | undefined
}
/**
* Maintains the rendered history window for a session timeline.
*
* It keeps initial paint bounded to recent turns, reveals cached turns in
* small batches while scrolling upward, and prefetches older history near top.
*/
function createSessionHistoryWindow(input: SessionHistoryWindowInput) {
const turnInit = 10
const turnBatch = 8
const turnScrollThreshold = 200
const turnPrefetchBuffer = 16
const prefetchCooldownMs = 400
const prefetchNoGrowthLimit = 2
const [state, setState] = createStore({
turnID: undefined as string | undefined,
turnStart: 0,
prefetchUntil: 0,
prefetchNoGrowth: 0,
})
const initialTurnStart = (len: number) => (len > turnInit ? len - turnInit : 0)
const turnStart = createMemo(() => {
const id = input.sessionID()
const len = input.visibleUserMessages().length
if (!id || len <= 0) return 0
if (state.turnID !== id) return initialTurnStart(len)
if (state.turnStart <= 0) return 0
if (state.turnStart >= len) return initialTurnStart(len)
return state.turnStart
})
const setTurnStart = (start: number) => {
const id = input.sessionID()
const next = start > 0 ? start : 0
if (!id) {
setState({ turnID: undefined, turnStart: next })
return
}
setState({ turnID: id, turnStart: next })
}
const renderedUserMessages = createMemo(
() => {
const msgs = input.visibleUserMessages()
const start = turnStart()
if (start <= 0) return msgs
return msgs.slice(start)
},
emptyUserMessages,
{
equals: same,
},
)
const preserveScroll = (fn: () => void) => {
const el = input.scroller()
if (!el) {
fn()
return
}
const beforeTop = el.scrollTop
fn()
// SolidJS updates the DOM synchronously. Force reflow so the browser
// processes the new layout, then restore scrollTop before paint.
// With column-reverse + overflow-anchor:none the same scrollTop value
// keeps the same distance from the bottom — no delta math needed.
void el.scrollHeight
el.scrollTop = beforeTop
}
const backfillTurns = () => {
const start = turnStart()
if (start <= 0) return
const next = start - turnBatch
const nextStart = next > 0 ? next : 0
preserveScroll(() => setTurnStart(nextStart))
}
/** Button path: reveal all cached turns, fetch older history, reveal one batch. */
const loadAndReveal = async () => {
const id = input.sessionID()
if (!id) return
const start = turnStart()
const beforeVisible = input.visibleUserMessages().length
if (start > 0) setTurnStart(0)
if (!input.historyMore() || input.historyLoading()) return
await input.loadMore(id)
if (input.sessionID() !== id) return
const afterVisible = input.visibleUserMessages().length
const growth = afterVisible - beforeVisible
if (state.prefetchNoGrowth) setState("prefetchNoGrowth", 0)
if (growth <= 0) return
if (turnStart() !== 0) return
const target = Math.min(afterVisible, Math.max(beforeVisible, renderedUserMessages().length) + turnBatch)
const nextStart = Math.max(0, afterVisible - target)
preserveScroll(() => setTurnStart(nextStart))
}
/** Scroll/prefetch path: fetch older history from server. */
const fetchOlderMessages = async (opts?: { prefetch?: boolean }) => {
const id = input.sessionID()
if (!id) return
if (!input.historyMore() || input.historyLoading()) return
if (opts?.prefetch) {
const now = Date.now()
if (state.prefetchUntil > now) return
if (state.prefetchNoGrowth >= prefetchNoGrowthLimit) return
setState("prefetchUntil", now + prefetchCooldownMs)
}
const start = turnStart()
const beforeVisible = input.visibleUserMessages().length
const beforeRendered = start <= 0 ? beforeVisible : renderedUserMessages().length
await input.loadMore(id)
if (input.sessionID() !== id) return
const afterVisible = input.visibleUserMessages().length
const growth = afterVisible - beforeVisible
if (opts?.prefetch) {
setState("prefetchNoGrowth", growth > 0 ? 0 : state.prefetchNoGrowth + 1)
} else if (growth > 0 && state.prefetchNoGrowth) {
setState("prefetchNoGrowth", 0)
}
if (growth <= 0) return
if (turnStart() !== start) return
const reveal = !opts?.prefetch
const currentRendered = renderedUserMessages().length
const base = Math.max(beforeRendered, currentRendered)
const target = reveal ? Math.min(afterVisible, base + turnBatch) : base
const nextStart = Math.max(0, afterVisible - target)
preserveScroll(() => setTurnStart(nextStart))
}
const onScrollerScroll = () => {
if (!input.userScrolled()) return
const el = input.scroller()
if (!el) return
// With column-reverse, distance from top = scrollHeight - clientHeight + scrollTop
if (el.scrollHeight - el.clientHeight + el.scrollTop >= turnScrollThreshold) return
const start = turnStart()
if (start > 0) {
if (start <= turnPrefetchBuffer) {
void fetchOlderMessages({ prefetch: true })
}
backfillTurns()
return
}
void fetchOlderMessages()
}
createEffect(
on(
input.sessionID,
() => {
setState({ prefetchUntil: 0, prefetchNoGrowth: 0 })
},
{ defer: true },
),
)
createEffect(
on(
() => [input.sessionID(), input.messagesReady()] as const,
([id, ready]) => {
if (!id || !ready) return
setTurnStart(initialTurnStart(input.visibleUserMessages().length))
},
{ defer: true },
),
)
return {
turnStart,
setTurnStart,
renderedUserMessages,
loadAndReveal,
onScrollerScroll,
}
}
export default function Page() {
const layout = useLayout()
const local = useLocal()
@@ -44,6 +266,19 @@ export default function Page() {
const sdk = useSDK()
const prompt = usePrompt()
const comments = useComments()
const [searchParams, setSearchParams] = useSearchParams<{ prompt?: string }>()
createEffect(() => {
if (!untrack(() => prompt.ready())) return
prompt.ready()
untrack(() => {
if (params.id || !prompt.ready()) return
const text = searchParams.prompt
if (!text) return
prompt.set([{ type: "text", content: text, start: 0, end: text.length }], text.length)
setSearchParams({ ...searchParams, prompt: undefined })
})
})
const [ui, setUi] = createStore({
pendingMessage: undefined as string | undefined,
@@ -53,7 +288,6 @@ export default function Page() {
bottom: true,
},
})
const composer = createSessionComposerState()
const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`)
@@ -138,24 +372,6 @@ export default function Page() {
if (path) file.load(path)
})
createEffect(() => {
const current = tabs().all()
if (current.length === 0) return
const next = normalizeTabs(current)
if (same(current, next)) return
tabs().setAll(next)
const active = tabs().active()
if (!active) return
if (!active.startsWith("file://")) return
const normalized = normalizeTab(active)
if (active === normalized) return
tabs().setActive(normalized)
})
const info = createMemo(() => (params.id ? sync.session.get(params.id) : undefined))
const diffs = createMemo(() => (params.id ? (sync.data.session_diff[params.id] ?? []) : []))
const reviewCount = createMemo(() => Math.max(info()?.summary?.files ?? 0, diffs().length))
@@ -178,7 +394,6 @@ export default function Page() {
return sync.session.history.loading(id)
})
const emptyUserMessages: UserMessage[] = []
const userMessages = createMemo(
() => messages().filter((m) => m.role === "user") as UserMessage[],
emptyUserMessages,
@@ -211,29 +426,38 @@ export default function Page() {
const [store, setStore] = createStore({
messageId: undefined as string | undefined,
turnStart: 0,
mobileTab: "session" as "session" | "changes",
changes: "session" as "session" | "turn",
newSessionWorktree: "main",
deferRender: false,
})
let deferFrame: number | undefined
let deferTimer: ReturnType<typeof setTimeout> | undefined
createComputed((prev) => {
const key = sessionKey()
if (key !== prev) {
if (deferFrame !== undefined) cancelAnimationFrame(deferFrame)
if (deferTimer !== undefined) clearTimeout(deferTimer)
setStore("deferRender", true)
deferFrame = requestAnimationFrame(() => {
deferFrame = undefined
deferTimer = setTimeout(() => {
deferTimer = undefined
setStore("deferRender", false)
}, 0)
})
}
return key
}, sessionKey())
onCleanup(() => {
if (deferFrame !== undefined) cancelAnimationFrame(deferFrame)
if (deferTimer !== undefined) clearTimeout(deferTimer)
})
const turnDiffs = createMemo(() => lastUserMessage()?.summary?.diffs ?? [])
const reviewDiffs = createMemo(() => (store.changes === "session" ? diffs() : turnDiffs()))
const renderedUserMessages = createMemo(
() => {
const msgs = visibleUserMessages()
const start = store.turnStart
if (start <= 0) return msgs
if (start >= msgs.length) return emptyUserMessages
return msgs.slice(start)
},
emptyUserMessages,
{
equals: same,
},
)
const newSessionWorktree = createMemo(() => {
if (store.newSessionWorktree === "create") return "create"
const project = sync.project
@@ -241,11 +465,6 @@ export default function Page() {
return "main"
})
const activeMessage = createMemo(() => {
if (!store.messageId) return lastUserMessage()
const found = visibleUserMessages()?.find((m) => m.id === store.messageId)
return found ?? lastUserMessage()
})
const setActiveMessage = (message: UserMessage | undefined) => {
setStore("messageId", message?.id)
}
@@ -302,13 +521,15 @@ export default function Page() {
const hasScrollGesture = () => Date.now() - ui.scrollGesture < scrollGestureWindowMs
createEffect(() => {
sdk.directory
const id = params.id
if (!id) return
void sync.session.sync(id)
void sync.session.todo(id)
})
createEffect(
on([() => sdk.directory, () => params.id] as const, ([, id]) => {
if (!id) return
untrack(() => {
void sync.session.sync(id)
void sync.session.todo(id)
})
}),
)
createEffect(
on(
@@ -405,11 +626,6 @@ export default function Page() {
saveLabel: language.t("common.save"),
}))
const isEditableTarget = (target: EventTarget | null | undefined) => {
if (!(target instanceof HTMLElement)) return false
return /^(INPUT|TEXTAREA|SELECT|BUTTON)$/.test(target.tagName) || target.isContentEditable
}
const deepActiveElement = () => {
let current: Element | null = document.activeElement
while (current instanceof HTMLElement && current.shadowRoot?.activeElement) {
@@ -478,7 +694,11 @@ export default function Page() {
on(
sessionKey,
() => {
setTree({ reviewScroll: undefined, pendingDiff: undefined, activeDiff: undefined })
setTree({
reviewScroll: undefined,
pendingDiff: undefined,
activeDiff: undefined,
})
},
{ defer: true },
),
@@ -499,11 +719,26 @@ export default function Page() {
const openReviewFile = createOpenReviewFile({
showAllFiles,
openReviewPanel,
tabForPath: file.tab,
openTab: tabs().open,
setActive: tabs().setActive,
setSelectedLines: file.setSelectedLines,
loadFile: file.load,
})
onMount(() => {
const open = (event: Event) => {
const detail = (event as CustomEvent<{ path?: string; line?: number }>).detail
const path = detail?.path
if (!path) return
openReviewFile(path, detail?.line)
}
window.addEventListener("opencode:open-file-path", open)
onCleanup(() => window.removeEventListener("opencode:open-file-path", open))
})
const changesOptions = ["session", "turn"] as const
const changesOptionsList = [...changesOptions]
@@ -535,35 +770,12 @@ export default function Page() {
loadingClass: string
emptyClass: string
}) => (
<Switch>
<Match when={store.changes === "turn" && !!params.id}>
<SessionReviewTab
title={changesTitle()}
empty={emptyTurn()}
diffs={reviewDiffs}
view={view}
diffStyle={input.diffStyle}
onDiffStyleChange={input.onDiffStyleChange}
onScrollRef={(el) => setTree("reviewScroll", el)}
focusedFile={tree.activeDiff}
onLineComment={(comment) => addCommentToContext({ ...comment, origin: "review" })}
onLineCommentUpdate={updateCommentInContext}
onLineCommentDelete={removeCommentFromContext}
lineCommentActions={reviewCommentActions()}
comments={comments.all()}
focusedComment={comments.focus()}
onFocusedCommentChange={comments.setFocus}
onViewFile={openReviewFile}
classes={input.classes}
/>
</Match>
<Match when={hasReview()}>
<Show
when={diffsReady()}
fallback={<div class={input.loadingClass}>{language.t("session.review.loadingChanges")}</div>}
>
<Show when={!store.deferRender}>
<Switch>
<Match when={store.changes === "turn" && !!params.id}>
<SessionReviewTab
title={changesTitle()}
empty={emptyTurn()}
diffs={reviewDiffs}
view={view}
diffStyle={input.diffStyle}
@@ -580,39 +792,64 @@ export default function Page() {
onViewFile={openReviewFile}
classes={input.classes}
/>
</Show>
</Match>
<Match when={true}>
<SessionReviewTab
title={changesTitle()}
empty={
store.changes === "turn" ? (
emptyTurn()
) : (
<div class={input.emptyClass}>
<Mark class="w-14 opacity-10" />
<div class="text-14-regular text-text-weak max-w-56">{language.t(reviewEmptyKey())}</div>
</div>
)
}
diffs={reviewDiffs}
view={view}
diffStyle={input.diffStyle}
onDiffStyleChange={input.onDiffStyleChange}
onScrollRef={(el) => setTree("reviewScroll", el)}
focusedFile={tree.activeDiff}
onLineComment={(comment) => addCommentToContext({ ...comment, origin: "review" })}
onLineCommentUpdate={updateCommentInContext}
onLineCommentDelete={removeCommentFromContext}
lineCommentActions={reviewCommentActions()}
comments={comments.all()}
focusedComment={comments.focus()}
onFocusedCommentChange={comments.setFocus}
onViewFile={openReviewFile}
classes={input.classes}
/>
</Match>
</Switch>
</Match>
<Match when={hasReview()}>
<Show
when={diffsReady()}
fallback={<div class={input.loadingClass}>{language.t("session.review.loadingChanges")}</div>}
>
<SessionReviewTab
title={changesTitle()}
diffs={reviewDiffs}
view={view}
diffStyle={input.diffStyle}
onDiffStyleChange={input.onDiffStyleChange}
onScrollRef={(el) => setTree("reviewScroll", el)}
focusedFile={tree.activeDiff}
onLineComment={(comment) => addCommentToContext({ ...comment, origin: "review" })}
onLineCommentUpdate={updateCommentInContext}
onLineCommentDelete={removeCommentFromContext}
lineCommentActions={reviewCommentActions()}
comments={comments.all()}
focusedComment={comments.focus()}
onFocusedCommentChange={comments.setFocus}
onViewFile={openReviewFile}
classes={input.classes}
/>
</Show>
</Match>
<Match when={true}>
<SessionReviewTab
title={changesTitle()}
empty={
store.changes === "turn" ? (
emptyTurn()
) : (
<div class={input.emptyClass}>
<Mark class="w-14 opacity-10" />
<div class="text-14-regular text-text-weak max-w-56">{language.t(reviewEmptyKey())}</div>
</div>
)
}
diffs={reviewDiffs}
view={view}
diffStyle={input.diffStyle}
onDiffStyleChange={input.onDiffStyleChange}
onScrollRef={(el) => setTree("reviewScroll", el)}
focusedFile={tree.activeDiff}
onLineComment={(comment) => addCommentToContext({ ...comment, origin: "review" })}
onLineCommentUpdate={updateCommentInContext}
onLineCommentDelete={removeCommentFromContext}
lineCommentActions={reviewCommentActions()}
comments={comments.all()}
focusedComment={comments.focus()}
onFocusedCommentChange={comments.setFocus}
onViewFile={openReviewFile}
classes={input.classes}
/>
</Match>
</Switch>
</Show>
)
const reviewPanel = () => (
@@ -823,7 +1060,10 @@ export default function Page() {
const updateScrollState = (el: HTMLDivElement) => {
const max = el.scrollHeight - el.clientHeight
const overflow = max > 1
const bottom = !overflow || el.scrollTop >= max - 2
// If auto-scroll is tracking the bottom, always report bottom: true
// to prevent the scroll-down arrow from flashing during height animations
// With column-reverse, scrollTop=0 is at the bottom
const bottom = !overflow || Math.abs(el.scrollTop) <= 2 || !autoScroll.userScrolled()
if (ui.scroll.overflow === overflow && ui.scroll.bottom === bottom) return
setUi("scroll", { overflow, bottom })
@@ -846,7 +1086,7 @@ export default function Page() {
const resumeScroll = () => {
setStore("messageId", undefined)
autoScroll.forceScrollToBottom()
autoScroll.smoothScrollToBottom()
clearMessageHash()
const el = scroller
@@ -894,88 +1134,16 @@ export default function Page() {
},
)
const turnInit = 20
const turnBatch = 20
let turnHandle: number | undefined
let turnIdle = false
function cancelTurnBackfill() {
const handle = turnHandle
if (handle === undefined) return
turnHandle = undefined
if (turnIdle && window.cancelIdleCallback) {
window.cancelIdleCallback(handle)
return
}
clearTimeout(handle)
}
function scheduleTurnBackfill() {
if (turnHandle !== undefined) return
if (store.turnStart <= 0) return
if (window.requestIdleCallback) {
turnIdle = true
turnHandle = window.requestIdleCallback(() => {
turnHandle = undefined
backfillTurns()
})
return
}
turnIdle = false
turnHandle = window.setTimeout(() => {
turnHandle = undefined
backfillTurns()
}, 0)
}
function backfillTurns() {
const start = store.turnStart
if (start <= 0) return
const next = start - turnBatch
const nextStart = next > 0 ? next : 0
const el = scroller
if (!el) {
setStore("turnStart", nextStart)
scheduleTurnBackfill()
return
}
const beforeTop = el.scrollTop
const beforeHeight = el.scrollHeight
setStore("turnStart", nextStart)
requestAnimationFrame(() => {
const delta = el.scrollHeight - beforeHeight
if (!delta) return
el.scrollTop = beforeTop + delta
})
scheduleTurnBackfill()
}
createEffect(
on(
() => [params.id, messagesReady()] as const,
([id, ready]) => {
cancelTurnBackfill()
setStore("turnStart", 0)
if (!id || !ready) return
const len = visibleUserMessages().length
const start = len > turnInit ? len - turnInit : 0
setStore("turnStart", start)
scheduleTurnBackfill()
},
{ defer: true },
),
)
const historyWindow = createSessionHistoryWindow({
sessionID: () => params.id,
messagesReady,
visibleUserMessages,
historyMore,
historyLoading,
loadMore: (sessionID) => sync.session.history.loadMore(sessionID),
userScrolled: autoScroll.userScrolled,
scroller: () => scroller,
})
createResizeObserver(
() => promptDock,
@@ -986,7 +1154,8 @@ export default function Page() {
const el = scroller
const delta = next - dockHeight
const stick = el ? el.scrollHeight - el.clientHeight - el.scrollTop < 10 + Math.max(0, delta) : false
// With column-reverse, near bottom = scrollTop near 0
const stick = el ? Math.abs(el.scrollTop) < 10 + Math.max(0, delta) : false
dockHeight = next
@@ -1002,13 +1171,12 @@ export default function Page() {
sessionID: () => params.id,
messagesReady,
visibleUserMessages,
turnStart: () => store.turnStart,
turnStart: historyWindow.turnStart,
currentMessageId: () => store.messageId,
pendingMessage: () => ui.pendingMessage,
setPendingMessage: (value) => setUi("pendingMessage", value),
setActiveMessage,
setTurnStart: (value) => setStore("turnStart", value),
scheduleTurnBackfill,
setTurnStart: historyWindow.setTurnStart,
autoScroll,
scroller: () => scroller,
anchor,
@@ -1021,7 +1189,6 @@ export default function Page() {
})
onCleanup(() => {
cancelTurnBackfill()
document.removeEventListener("keydown", handleKeyDown)
scrollSpy.destroy()
if (scrollStateFrame !== undefined) cancelAnimationFrame(scrollStateFrame)
@@ -1029,6 +1196,7 @@ export default function Page() {
return (
<div class="relative bg-background-base size-full overflow-hidden flex flex-col">
{import.meta.env.DEV && <AnimationDebugPanel />}
<SessionHeader />
<div class="flex-1 min-h-0 flex flex-col md:flex-row">
<SessionMobileTabs
@@ -1054,54 +1222,49 @@ export default function Page() {
<div class="flex-1 min-h-0 overflow-hidden">
<Switch>
<Match when={params.id}>
<Show when={activeMessage()}>
<MessageTimeline
mobileChanges={mobileChanges()}
mobileFallback={reviewContent({
diffStyle: "unified",
classes: {
root: "pb-8",
header: "px-4",
container: "px-4",
},
loadingClass: "px-4 py-4 text-text-weak",
emptyClass: "h-full pb-30 flex flex-col items-center justify-center text-center gap-6",
})}
scroll={ui.scroll}
onResumeScroll={resumeScroll}
setScrollRef={setScrollRef}
onScheduleScrollState={scheduleScrollState}
onAutoScrollHandleScroll={autoScroll.handleScroll}
onMarkScrollGesture={markScrollGesture}
hasScrollGesture={hasScrollGesture}
isDesktop={isDesktop()}
onScrollSpyScroll={scrollSpy.onScroll}
onAutoScrollInteraction={autoScroll.handleInteraction}
centered={centered()}
setContentRef={(el) => {
content = el
autoScroll.contentRef(el)
<MessageTimeline
mobileChanges={mobileChanges()}
mobileFallback={reviewContent({
diffStyle: "unified",
classes: {
root: "pb-8",
header: "px-4",
container: "px-4",
},
loadingClass: "px-4 py-4 text-text-weak",
emptyClass: "h-full pb-30 flex flex-col items-center justify-center text-center gap-6",
})}
scroll={ui.scroll}
onResumeScroll={resumeScroll}
setScrollRef={setScrollRef}
onScheduleScrollState={scheduleScrollState}
onAutoScrollHandleScroll={autoScroll.handleScroll}
onMarkScrollGesture={markScrollGesture}
hasScrollGesture={hasScrollGesture}
isDesktop={isDesktop()}
onScrollSpyScroll={scrollSpy.onScroll}
onTurnBackfillScroll={historyWindow.onScrollerScroll}
onAutoScrollInteraction={autoScroll.handleInteraction}
onPreserveScrollAnchor={autoScroll.preserve}
centered={centered()}
setContentRef={(el) => {
content = el
autoScroll.contentRef(el)
const root = scroller
if (root) scheduleScrollState(root)
}}
turnStart={store.turnStart}
onRenderEarlier={() => setStore("turnStart", 0)}
historyMore={historyMore()}
historyLoading={historyLoading()}
onLoadEarlier={() => {
const id = params.id
if (!id) return
setStore("turnStart", 0)
sync.session.history.loadMore(id)
}}
renderedUserMessages={renderedUserMessages()}
anchor={anchor}
onRegisterMessage={scrollSpy.register}
onUnregisterMessage={scrollSpy.unregister}
lastUserMessageID={lastUserMessage()?.id}
/>
</Show>
const root = scroller
if (root) scheduleScrollState(root)
}}
turnStart={historyWindow.turnStart()}
historyMore={historyMore()}
historyLoading={historyLoading()}
onLoadEarlier={() => {
void historyWindow.loadAndReveal()
}}
renderedUserMessages={historyWindow.renderedUserMessages()}
anchor={anchor}
onRegisterMessage={scrollSpy.register}
onUnregisterMessage={scrollSpy.unregister}
/>
</Match>
<Match when={true}>
<NewSessionView
@@ -1127,6 +1290,7 @@ export default function Page() {
<SessionComposerRegion
state={composer}
ready={!store.deferRender && messagesReady()}
centered={centered()}
inputRef={(el) => {
inputRef = el

View File

@@ -1,5 +1,7 @@
import { Show, createEffect, createMemo } from "solid-js"
import { Show, createMemo, createSignal, createEffect } from "solid-js"
import { useParams } from "@solidjs/router"
import { useSpring } from "@opencode-ai/ui/motion-spring"
import { useElementHeight } from "@opencode-ai/ui/hooks"
import { PromptInput } from "@/components/prompt-input"
import { useLanguage } from "@/context/language"
import { usePrompt } from "@/context/prompt"
@@ -7,7 +9,9 @@ import { getSessionHandoff, setSessionHandoff } from "@/pages/session/handoff"
import { SessionPermissionDock } from "@/pages/session/composer/session-permission-dock"
import { SessionQuestionDock } from "@/pages/session/composer/session-question-dock"
import type { SessionComposerState } from "@/pages/session/composer/session-composer-state"
import { SessionTodoDock } from "@/pages/session/composer/session-todo-dock"
import { SessionTodoDock, COLLAPSED_HEIGHT } from "@/pages/session/composer/session-todo-dock"
const DOCK_SPRING = { visualDuration: 0.3, bounce: 0 }
export function SessionComposerRegion(props: {
state: SessionComposerState
@@ -43,6 +47,16 @@ export function SessionComposerRegion(props: {
setSessionHandoff(sessionKey(), { prompt: previewPrompt() })
})
const open = createMemo(() => props.state.dock() && !props.state.closing())
const progress = useSpring(
() => (open() ? 1 : 0),
DOCK_SPRING,
)
const dock = createMemo(() => props.state.dock() || progress() > 0.001)
const [contentRef, setContentRef] = createSignal<HTMLDivElement>()
const height = useElementHeight(contentRef, 320)
const full = createMemo(() => Math.max(COLLAPSED_HEIGHT, height()))
return (
<div
ref={props.setPromptDockRef}
@@ -87,30 +101,33 @@ export function SessionComposerRegion(props: {
</div>
}
>
<Show when={props.state.dock()}>
<Show when={dock()}>
<div
classList={{
"transition-[max-height,opacity,transform] duration-[400ms] ease-out overflow-hidden": true,
"max-h-[320px]": !props.state.closing(),
"max-h-0 pointer-events-none": props.state.closing(),
"opacity-0 translate-y-9": props.state.closing() || props.state.opening(),
"opacity-100 translate-y-0": !props.state.closing() && !props.state.opening(),
"overflow-hidden": true,
"pointer-events-none": progress() < 0.98,
}}
style={{
"max-height": `${full() * progress()}px`,
}}
>
<SessionTodoDock
todos={props.state.todos()}
title={language.t("session.todo.title")}
collapseLabel={language.t("session.todo.collapse")}
expandLabel={language.t("session.todo.expand")}
/>
<div ref={setContentRef}>
<SessionTodoDock
todos={props.state.todos()}
title={language.t("session.todo.title")}
collapseLabel={language.t("session.todo.collapse")}
expandLabel={language.t("session.todo.expand")}
dockProgress={progress()}
/>
</div>
</div>
</Show>
<div
classList={{
"relative z-10": true,
"transition-[margin] duration-[400ms] ease-out": true,
"-mt-9": props.state.dock() && !props.state.closing(),
"mt-0": !props.state.dock() || props.state.closing(),
}}
style={{
"margin-top": `${-36 * progress()}px`,
}}
>
<PromptInput

View File

@@ -29,7 +29,11 @@ export function createSessionComposerBlocked() {
})
}
export function createSessionComposerState() {
export function createSessionComposerState(
options?: {
closeMs?: number | (() => number)
},
) {
const params = useParams()
const sdk = useSDK()
const sync = useSync()
@@ -96,12 +100,19 @@ export function createSessionComposerState() {
let timer: number | undefined
let raf: number | undefined
const closeMs = () => {
const value = options?.closeMs
if (typeof value === "function") return Math.max(0, value())
if (typeof value === "number") return Math.max(0, value)
return 400
}
const scheduleClose = () => {
if (timer) window.clearTimeout(timer)
timer = window.setTimeout(() => {
setStore({ dock: false, closing: false })
timer = undefined
}, 400)
}, closeMs())
}
createEffect(

View File

@@ -1,10 +1,20 @@
import type { Todo } from "@opencode-ai/sdk/v2"
import { AnimatedNumber } from "@opencode-ai/ui/animated-number"
import { Checkbox } from "@opencode-ai/ui/checkbox"
import { DockTray } from "@opencode-ai/ui/dock-surface"
import { IconButton } from "@opencode-ai/ui/icon-button"
import { For, Show, createEffect, createMemo, createSignal, on, onCleanup } from "solid-js"
import { useSpring } from "@opencode-ai/ui/motion-spring"
import { TextReveal } from "@opencode-ai/ui/text-reveal"
import { TextStrikethrough } from "@opencode-ai/ui/text-strikethrough"
import { useElementHeight } from "@opencode-ai/ui/hooks"
import { Index, createEffect, createMemo, createSignal, on, onCleanup } from "solid-js"
import { createStore } from "solid-js/store"
const COLLAPSE_SPRING = { visualDuration: 0.3, bounce: 0 }
export const COLLAPSED_HEIGHT = 78
const SUBTITLE = { duration: 600, travel: 25, edge: 17 }
const COUNT = { duration: 600, mask: 18, maskHeight: 0, widthDuration: 560 }
function dot(status: Todo["status"]) {
if (status !== "in_progress") return undefined
return (
@@ -30,19 +40,22 @@ function dot(status: Todo["status"]) {
)
}
export function SessionTodoDock(props: { todos: Todo[]; title: string; collapseLabel: string; expandLabel: string }) {
export function SessionTodoDock(props: {
todos: Todo[]
title: string
collapseLabel: string
expandLabel: string
dockProgress?: number
}) {
const [store, setStore] = createStore({
collapsed: false,
})
const toggle = () => setStore("collapsed", (value) => !value)
const summary = createMemo(() => {
const total = props.todos.length
if (total === 0) return ""
const completed = props.todos.filter((todo) => todo.status === "completed").length
return `${completed} of ${total} ${props.title.toLowerCase()} completed`
})
const total = createMemo(() => props.todos.length)
const done = createMemo(() => props.todos.filter((todo) => todo.status === "completed").length)
const label = createMemo(() => `${done()} of ${total()} ${props.title.toLowerCase()} completed`)
const active = createMemo(
() =>
@@ -53,56 +66,108 @@ export function SessionTodoDock(props: { todos: Todo[]; title: string; collapseL
)
const preview = createMemo(() => active()?.content ?? "")
const collapse = useSpring(() => (store.collapsed ? 1 : 0), COLLAPSE_SPRING)
const shut = createMemo(() => 1 - (props.dockProgress ?? 1))
const hide = createMemo(() => Math.max(collapse(), shut()))
let contentRef: HTMLDivElement | undefined
const height = useElementHeight(() => contentRef, 320)
const full = createMemo(() => Math.max(COLLAPSED_HEIGHT, height()))
return (
<DockTray
data-component="session-todo-dock"
classList={{
"h-[78px]": store.collapsed,
style={{
"overflow-x": "visible",
"overflow-y": "hidden",
"max-height": `${Math.max(COLLAPSED_HEIGHT, full() - collapse() * (full() - COLLAPSED_HEIGHT))}px`,
}}
>
<div
data-action="session-todo-toggle"
class="pl-3 pr-2 py-2 flex items-center gap-2"
role="button"
tabIndex={0}
onClick={toggle}
onKeyDown={(event) => {
if (event.key !== "Enter" && event.key !== " ") return
event.preventDefault()
toggle()
}}
>
<span class="text-14-regular text-text-strong cursor-default">{summary()}</span>
<Show when={store.collapsed}>
<div class="ml-1 flex-1 min-w-0">
<Show when={preview()}>
<div class="text-14-regular text-text-base truncate cursor-default">{preview()}</div>
</Show>
<div ref={contentRef}>
<div
data-action="session-todo-toggle"
class="pl-3 pr-2 py-2 flex items-center gap-2 overflow-visible"
role="button"
tabIndex={0}
onClick={toggle}
onKeyDown={(event) => {
if (event.key !== "Enter" && event.key !== " ") return
event.preventDefault()
toggle()
}}
>
<span
class="text-14-regular text-text-strong cursor-default inline-flex items-baseline shrink-0 whitespace-nowrap overflow-visible"
aria-label={label()}
style={{
"--tool-motion-odometer-ms": `${COUNT.duration}ms`,
"--tool-motion-mask": `${COUNT.mask}%`,
"--tool-motion-mask-height": `${COUNT.maskHeight}px`,
"--tool-motion-spring-ms": `${COUNT.widthDuration}ms`,
opacity: `${1 - shut()}`,
filter: shut() > 0.01 ? `blur(${shut() * 2}px)` : "none",
}}
>
<AnimatedNumber value={done()} />
<span class="mx-1">of</span>
<AnimatedNumber value={total()} />
<span>&nbsp;{props.title.toLowerCase()} completed</span>
</span>
<div
data-slot="session-todo-preview"
class="ml-1 min-w-0 overflow-hidden"
style={{
flex: "1 1 auto",
"max-width": "100%",
}}
>
<TextReveal
class="text-14-regular text-text-base cursor-default"
text={store.collapsed ? preview() : undefined}
duration={SUBTITLE.duration}
travel={SUBTITLE.travel}
edge={SUBTITLE.edge}
spring="cubic-bezier(0.34, 1, 0.64, 1)"
springSoft="cubic-bezier(0.34, 1, 0.64, 1)"
growOnly
truncate
/>
</div>
<div class="ml-auto">
<IconButton
data-action="session-todo-toggle-button"
data-collapsed={store.collapsed ? "true" : "false"}
icon="chevron-down"
size="normal"
variant="ghost"
style={{ transform: `rotate(${collapse() * 180}deg)` }}
onMouseDown={(event) => {
event.preventDefault()
event.stopPropagation()
}}
onClick={(event) => {
event.stopPropagation()
toggle()
}}
aria-label={store.collapsed ? props.expandLabel : props.collapseLabel}
/>
</div>
</Show>
<div classList={{ "ml-auto": !store.collapsed, "ml-1": store.collapsed }}>
<IconButton
data-action="session-todo-toggle-button"
icon="chevron-down"
size="normal"
variant="ghost"
classList={{ "rotate-180": store.collapsed }}
onMouseDown={(event) => {
event.preventDefault()
event.stopPropagation()
}}
onClick={(event) => {
event.stopPropagation()
toggle()
}}
aria-label={store.collapsed ? props.expandLabel : props.collapseLabel}
/>
</div>
</div>
<div data-slot="session-todo-list" hidden={store.collapsed}>
<TodoList todos={props.todos} open={!store.collapsed} />
<div
data-slot="session-todo-list"
class="pb-2"
aria-hidden={store.collapsed}
classList={{
"pointer-events-none": hide() > 0.1,
}}
style={{
opacity: `${1 - hide()}`,
filter: hide() > 0.01 ? `blur(${hide() * 2}px)` : "none",
visibility: hide() > 0.98 ? "hidden" : "visible",
}}
>
<TodoList todos={props.todos} open={!store.collapsed} />
</div>
</div>
</DockTray>
)
@@ -171,33 +236,39 @@ function TodoList(props: { todos: Todo[]; open: boolean }) {
}, 250)
}}
>
<For each={props.todos}>
<Index each={props.todos}>
{(todo) => (
<Checkbox
readOnly
checked={todo.status === "completed"}
indeterminate={todo.status === "in_progress"}
data-in-progress={todo.status === "in_progress" ? "" : undefined}
icon={dot(todo.status)}
style={{ "--checkbox-align": "flex-start", "--checkbox-offset": "1px" }}
checked={todo().status === "completed"}
indeterminate={todo().status === "in_progress"}
data-in-progress={todo().status === "in_progress" ? "" : undefined}
data-state={todo().status}
icon={dot(todo().status)}
style={{
"--checkbox-align": "flex-start",
"--checkbox-offset": "1px",
transition: "opacity 220ms var(--tool-motion-ease, cubic-bezier(0.22, 1, 0.36, 1))",
opacity: todo().status === "pending" ? "0.5" : "1",
}}
>
<span
<TextStrikethrough
active={todo().status === "completed" || todo().status === "cancelled"}
text={todo().content}
class="text-14-regular min-w-0 break-words"
classList={{
"text-text-weak": todo.status === "completed" || todo.status === "cancelled",
"text-text-strong": todo.status !== "completed" && todo.status !== "cancelled",
}}
style={{
"line-height": "var(--line-height-normal)",
"text-decoration":
todo.status === "completed" || todo.status === "cancelled" ? "line-through" : undefined,
transition:
"color 220ms var(--tool-motion-ease, cubic-bezier(0.22, 1, 0.36, 1))",
color:
todo().status === "completed" || todo().status === "cancelled"
? "var(--text-weak)"
: "var(--text-strong)",
}}
>
{todo.content}
</span>
/>
</Checkbox>
)}
</For>
</Index>
</div>
<div
class="pointer-events-none absolute top-0 left-0 right-0 h-4 transition-opacity duration-150"

View File

@@ -67,6 +67,7 @@ export function FileTabContent(props: { tab: string }) {
let scroll: HTMLDivElement | undefined
let scrollFrame: number | undefined
let restoreFrame: number | undefined
let pending: { x: number; y: number } | undefined
let codeScroll: HTMLElement[] = []
let find: FileSearchHandle | null = null
@@ -349,6 +350,15 @@ export function FileTabContent(props: { tab: string }) {
if (el.scrollLeft !== s.x) el.scrollLeft = s.x
}
const queueRestore = () => {
if (restoreFrame !== undefined) return
restoreFrame = requestAnimationFrame(() => {
restoreFrame = undefined
restoreScroll()
})
}
const handleScroll = (event: Event & { currentTarget: HTMLDivElement }) => {
if (codeScroll.length === 0) syncCodeScroll()
@@ -364,46 +374,29 @@ export function FileTabContent(props: { tab: string }) {
setNote("commenting", null)
}
createEffect(
on(
() => state()?.loaded,
(loaded) => {
if (!loaded) return
requestAnimationFrame(restoreScroll)
},
{ defer: true },
),
)
let prev = {
loaded: false,
ready: false,
active: false,
}
createEffect(
on(
() => file.ready(),
(ready) => {
if (!ready) return
requestAnimationFrame(restoreScroll)
},
{ defer: true },
),
)
createEffect(
on(
() => tabs().active() === props.tab,
(active) => {
if (!active) return
if (!state()?.loaded) return
requestAnimationFrame(restoreScroll)
},
),
)
createEffect(() => {
const loaded = !!state()?.loaded
const ready = file.ready()
const active = tabs().active() === props.tab
const restore = (loaded && !prev.loaded) || (ready && !prev.ready) || (active && loaded && !prev.active)
prev = { loaded, ready, active }
if (!restore) return
queueRestore()
})
onCleanup(() => {
for (const item of codeScroll) {
item.removeEventListener("scroll", handleCodeScroll)
}
if (scrollFrame === undefined) return
cancelAnimationFrame(scrollFrame)
if (scrollFrame !== undefined) cancelAnimationFrame(scrollFrame)
if (restoreFrame !== undefined) cancelAnimationFrame(restoreFrame)
})
const renderFile = (source: string) => (
@@ -421,7 +414,7 @@ export function FileTabContent(props: { tab: string }) {
selectedLines={activeSelection()}
commentedLines={commentedLines()}
onRendered={() => {
requestAnimationFrame(restoreScroll)
queueRestore()
}}
annotations={commentsUi.annotations()}
renderAnnotation={commentsUi.renderAnnotation}
@@ -440,7 +433,7 @@ export function FileTabContent(props: { tab: string }) {
mode: "auto",
path: path(),
current: state()?.content,
onLoad: () => requestAnimationFrame(restoreScroll),
onLoad: queueRestore,
onError: (args: { kind: "image" | "audio" | "svg" }) => {
if (args.kind !== "svg") return
showToast({

View File

@@ -6,17 +6,45 @@ describe("createOpenReviewFile", () => {
const calls: string[] = []
const openReviewFile = createOpenReviewFile({
showAllFiles: () => calls.push("show"),
openReviewPanel: () => calls.push("review"),
tabForPath: (path) => {
calls.push(`tab:${path}`)
return `file://${path}`
},
openTab: (tab) => calls.push(`open:${tab}`),
setActive: (tab) => calls.push(`active:${tab}`),
setSelectedLines: (path, range) => calls.push(`select:${path}:${range ? `${range.start}-${range.end}` : "none"}`),
loadFile: (path) => calls.push(`load:${path}`),
})
openReviewFile("src/a.ts")
expect(calls).toEqual(["show", "load:src/a.ts", "tab:src/a.ts", "open:file://src/a.ts"])
expect(calls).toEqual([
"tab:src/a.ts",
"show",
"review",
"load:src/a.ts",
"open:file://src/a.ts",
"active:file://src/a.ts",
"select:src/a.ts:none",
])
})
test("selects the requested line when provided", () => {
const calls: string[] = []
const openReviewFile = createOpenReviewFile({
showAllFiles: () => calls.push("show"),
openReviewPanel: () => calls.push("review"),
tabForPath: (path) => `file://${path}`,
openTab: () => calls.push("open"),
setActive: () => calls.push("active"),
setSelectedLines: (_path, range) => calls.push(`select:${range?.start}-${range?.end}`),
loadFile: () => calls.push("load"),
})
openReviewFile("src/a.ts", 12)
expect(calls).toContain("select:12-12")
})
})

View File

@@ -24,13 +24,22 @@ export const createOpenReviewFile = (input: {
showAllFiles: () => void
tabForPath: (path: string) => string
openTab: (tab: string) => void
setActive: (tab: string) => void
openReviewPanel: () => void
setSelectedLines: (path: string, range: { start: number; end: number } | null) => void
loadFile: (path: string) => any | Promise<void>
}) => {
return (path: string) => {
return (path: string, line?: number) => {
const tab = input.tabForPath(path)
batch(() => {
input.showAllFiles()
input.openReviewPanel()
const maybePromise = input.loadFile(path)
const openTab = () => input.openTab(input.tabForPath(path))
const openTab = () => {
input.openTab(tab)
input.setActive(tab)
input.setSelectedLines(path, line ? { start: line, end: line } : null)
}
if (maybePromise instanceof Promise) maybePromise.then(openTab)
else openTab()
})

View File

@@ -49,11 +49,11 @@ describe("shouldMarkBoundaryGesture", () => {
).toBe(true)
})
test("does not mark when nested scroller can consume movement", () => {
test("does not mark when scroller can consume movement", () => {
expect(
shouldMarkBoundaryGesture({
delta: 20,
scrollTop: 200,
scrollTop: 300,
scrollHeight: 1000,
clientHeight: 400,
}),

View File

@@ -14,8 +14,8 @@ export const shouldMarkBoundaryGesture = (input: {
if (max <= 1) return true
if (!input.delta) return false
if (input.delta < 0) return input.scrollTop + input.delta <= 0
const remaining = max - input.scrollTop
return input.delta > remaining
const top = Math.max(0, Math.min(max, input.scrollTop))
if (input.delta < 0) return -input.delta > top
const bottom = max - top
return input.delta > bottom
}

View File

@@ -1,4 +1,15 @@
import { For, createEffect, createMemo, on, onCleanup, Show, type JSX } from "solid-js"
import {
For,
Index,
createEffect,
createMemo,
createSignal,
on,
onCleanup,
Show,
startTransition,
type JSX,
} from "solid-js"
import { createStore, produce } from "solid-js/store"
import { useNavigate, useParams } from "@solidjs/router"
import { Button } from "@opencode-ai/ui/button"
@@ -10,10 +21,13 @@ import { Dialog } from "@opencode-ai/ui/dialog"
import { InlineInput } from "@opencode-ai/ui/inline-input"
import { SessionTurn } from "@opencode-ai/ui/session-turn"
import { ScrollView } from "@opencode-ai/ui/scroll-view"
import type { Part, TextPart, UserMessage } from "@opencode-ai/sdk/v2"
import { animate, type AnimationPlaybackControls, clearFadeStyles, FAST_SPRING } from "@opencode-ai/ui/motion"
import type { AssistantMessage, Message as MessageType, Part, TextPart, UserMessage } from "@opencode-ai/sdk/v2"
import { showToast } from "@opencode-ai/ui/toast"
import { Binary } from "@opencode-ai/util/binary"
import { getFilename } from "@opencode-ai/util/path"
import { shouldMarkBoundaryGesture, normalizeWheelDelta } from "@/pages/session/message-gesture"
import { errorMessage } from "@/pages/layout/helpers"
import { SessionContextUsage } from "@/components/session-context-usage"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { useLanguage } from "@/context/language"
@@ -31,6 +45,11 @@ type MessageComment = {
}
}
const emptyMessages: MessageType[] = []
const isDefaultSessionTitle = (title?: string) =>
!!title && /^(New session - |Child session - )\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/.test(title)
const messageComments = (parts: Part[]): MessageComment[] =>
parts.flatMap((part) => {
if (part.type !== "text" || !(part as TextPart).synthetic) return []
@@ -81,6 +100,132 @@ const markBoundaryGesture = (input: {
}
}
type StageConfig = {
init: number
batch: number
}
type TimelineStageInput = {
sessionKey: () => string
turnStart: () => number
messages: () => UserMessage[]
config: StageConfig
}
/**
* Defer-mounts small timeline windows so revealing older turns does not
* block first paint with a large DOM mount.
*
* Once staging completes for a session it never re-stages — backfill and
* new messages render immediately.
*/
function createTimelineStaging(input: TimelineStageInput) {
const [state, setState] = createStore({
activeSession: "",
completedSession: "",
count: 0,
})
const [readySession, setReadySession] = createSignal("")
let active = ""
const stagedCount = createMemo(() => {
const total = input.messages().length
if (input.turnStart() <= 0) return total
if (state.completedSession === input.sessionKey()) return total
const init = Math.min(total, input.config.init)
if (state.count <= init) return init
if (state.count >= total) return total
return state.count
})
const stagedUserMessages = createMemo(() => {
const list = input.messages()
const count = stagedCount()
if (count >= list.length) return list
return list.slice(Math.max(0, list.length - count))
})
let frame: number | undefined
const cancel = () => {
if (frame === undefined) return
cancelAnimationFrame(frame)
frame = undefined
}
const scheduleReady = (sessionKey: string) => {
if (input.sessionKey() !== sessionKey) return
if (readySession() === sessionKey) return
setReadySession(sessionKey)
}
createEffect(
on(
() => [input.sessionKey(), input.turnStart() > 0, input.messages().length] as const,
([sessionKey, isWindowed, total]) => {
const switched = active !== sessionKey
if (switched) {
active = sessionKey
setReadySession("")
}
const staging = state.activeSession === sessionKey && state.completedSession !== sessionKey
const shouldStage = isWindowed && total > input.config.init && state.completedSession !== sessionKey
if (staging && !switched && shouldStage && frame !== undefined) return
cancel()
if (shouldStage) setReadySession("")
if (!shouldStage) {
setState({
activeSession: "",
completedSession: isWindowed ? sessionKey : state.completedSession,
count: total,
})
if (total <= 0) {
setReadySession("")
return
}
if (readySession() !== sessionKey) scheduleReady(sessionKey)
return
}
let count = Math.min(total, input.config.init)
if (staging) count = Math.min(total, Math.max(count, state.count))
setState({ activeSession: sessionKey, count })
const step = () => {
if (input.sessionKey() !== sessionKey) {
frame = undefined
return
}
const currentTotal = input.messages().length
count = Math.min(currentTotal, count + input.config.batch)
startTransition(() => setState("count", count))
if (count >= currentTotal) {
setState({ completedSession: sessionKey, activeSession: "" })
frame = undefined
scheduleReady(sessionKey)
return
}
frame = requestAnimationFrame(step)
}
frame = requestAnimationFrame(step)
},
),
)
const isStaging = createMemo(() => {
const key = input.sessionKey()
return state.activeSession === key && state.completedSession !== key
})
const ready = createMemo(() => readySession() === input.sessionKey())
onCleanup(() => {
cancel()
})
return { messages: stagedUserMessages, isStaging, ready }
}
export function MessageTimeline(props: {
mobileChanges: boolean
mobileFallback: JSX.Element
@@ -93,11 +238,12 @@ export function MessageTimeline(props: {
hasScrollGesture: () => boolean
isDesktop: boolean
onScrollSpyScroll: () => void
onTurnBackfillScroll: () => void
onAutoScrollInteraction: (event: MouseEvent) => void
onPreserveScrollAnchor: (target: HTMLElement) => void
centered: boolean
setContentRef: (el: HTMLDivElement) => void
turnStart: number
onRenderEarlier: () => void
historyMore: boolean
historyLoading: boolean
onLoadEarlier: () => void
@@ -105,7 +251,6 @@ export function MessageTimeline(props: {
anchor: (id: string) => string
onRegisterMessage: (el: HTMLDivElement, id: string) => void
onUnregisterMessage: (id: string) => void
lastUserMessageID?: string
}) {
let touchGesture: number | undefined
@@ -117,16 +262,69 @@ export function MessageTimeline(props: {
const dialog = useDialog()
const language = useLanguage()
const trigger = (target: EventTarget | null) => {
const next =
target instanceof Element
? target.closest('[data-slot="collapsible-trigger"], [data-slot="accordion-trigger"]')
: undefined
if (!(next instanceof HTMLElement)) return
return next
}
const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`)
const sessionID = createMemo(() => params.id)
const sessionMessages = createMemo(() => {
const id = sessionID()
if (!id) return emptyMessages
return sync.data.message[id] ?? emptyMessages
})
const pending = createMemo(() =>
sessionMessages().findLast(
(item): item is AssistantMessage => item.role === "assistant" && typeof item.time.completed !== "number",
),
)
const sessionStatus = createMemo(() => sync.data.session_status[sessionID() ?? ""]?.type ?? "idle")
const activeMessageID = createMemo(() => {
const messages = sessionMessages()
const message = pending()
if (message?.parentID) {
const result = Binary.search(messages, message.parentID, (item) => item.id)
const parent = result.found ? messages[result.index] : messages.find((item) => item.id === message.parentID)
if (parent?.role === "user") return parent.id
}
if (sessionStatus() === "idle") return undefined
for (let i = messages.length - 1; i >= 0; i--) {
if (messages[i].role === "user") return messages[i].id
}
return undefined
})
const info = createMemo(() => {
const id = sessionID()
if (!id) return
return sync.session.get(id)
})
const titleValue = createMemo(() => info()?.title)
const titleValue = createMemo(() => {
const title = info()?.title
if (!title) return
if (isDefaultSessionTitle(title)) return language.t("command.session.new")
return title
})
const defaultTitle = createMemo(() => isDefaultSessionTitle(info()?.title))
const headerTitle = createMemo(
() => titleValue() ?? (props.renderedUserMessages.length ? language.t("command.session.new") : undefined),
)
const placeholderTitle = createMemo(() => defaultTitle() || (!info()?.title && props.renderedUserMessages.length > 0))
const parentID = createMemo(() => info()?.parentID)
const showHeader = createMemo(() => !!(titleValue() || parentID()))
const showHeader = createMemo(() => !!(headerTitle() || parentID()))
const stageCfg = { init: 1, batch: 3 }
const staging = createTimelineStaging({
sessionKey,
turnStart: () => props.turnStart,
messages: () => props.renderedUserMessages,
config: stageCfg,
})
const rendered = createMemo(() => staging.messages().map((message) => message.id))
const [title, setTitle] = createStore({
draft: "",
@@ -135,17 +333,168 @@ export function MessageTimeline(props: {
menuOpen: false,
pendingRename: false,
})
const [headerText, setHeaderText] = createStore({
session: sessionKey(),
value: headerTitle(),
prev: undefined as string | undefined,
muted: placeholderTitle(),
prevMuted: false,
})
let titleFrame: number | undefined
let headerAnim: AnimationPlaybackControls | undefined
let enterAnim: AnimationPlaybackControls | undefined
let leaveAnim: AnimationPlaybackControls | undefined
let titleRef: HTMLInputElement | undefined
let headerRef: HTMLDivElement | undefined
let enterRef: HTMLSpanElement | undefined
let leaveRef: HTMLSpanElement | undefined
const errorMessage = (err: unknown) => {
if (err && typeof err === "object" && "data" in err) {
const data = (err as { data?: { message?: string } }).data
if (data?.message) return data.message
}
if (err instanceof Error) return err.message
return language.t("common.requestFailed")
const clearHeaderAnim = () => {
headerAnim?.stop()
headerAnim = undefined
}
const animateHeader = () => {
const el = headerRef
if (!el) return
clearHeaderAnim()
// Only animate for new sessions (placeholder title); snap for existing sessions
if (headerText.muted) {
headerAnim = animate(el, { opacity: [0, 1] }, { type: "spring", visualDuration: 1.0, bounce: 0 })
headerAnim.finished.then(() => {
if (headerRef !== el) return
clearFadeStyles(el)
})
} else {
el.style.opacity = "1"
}
}
const clearTitleAnims = () => {
if (titleFrame !== undefined) {
cancelAnimationFrame(titleFrame)
titleFrame = undefined
}
enterAnim?.stop()
enterAnim = undefined
leaveAnim?.stop()
leaveAnim = undefined
}
const settleTitleEnter = () => {
if (enterRef) clearFadeStyles(enterRef)
}
const hideLeave = () => {
if (!leaveRef) return
leaveRef.style.opacity = "0"
leaveRef.style.filter = ""
leaveRef.style.transform = ""
}
const animateEnterSpan = () => {
if (!enterRef) return
enterAnim = animate(
enterRef,
{ opacity: [0, 1], filter: ["blur(2px)", "blur(0px)"], transform: ["translateY(-2px)", "translateY(0)"] },
FAST_SPRING,
)
enterAnim.finished.then(() => settleTitleEnter())
}
const crossfadeTitle = (nextTitle: string, nextMuted: boolean) => {
clearTitleAnims()
// snapshot old text into leave span before updating store
setHeaderText({ prev: headerText.value, prevMuted: headerText.muted })
// update to new text
setHeaderText({ value: nextTitle, muted: nextMuted })
// fade out leave span
if (leaveRef) {
leaveAnim = animate(
leaveRef,
{ opacity: [1, 0], filter: ["blur(0px)", "blur(2px)"], transform: ["translateY(0)", "translateY(2px)"] },
FAST_SPRING,
)
leaveAnim.finished.then(() => {
setHeaderText({ prev: undefined, prevMuted: false })
hideLeave()
})
}
animateEnterSpan()
}
const fadeInTitle = (nextTitle: string, nextMuted: boolean) => {
clearTitleAnims()
setHeaderText({ value: nextTitle, muted: nextMuted, prev: undefined, prevMuted: false })
animateEnterSpan()
}
const snapTitle = (nextTitle: string | undefined, nextMuted: boolean) => {
clearTitleAnims()
setHeaderText({ value: nextTitle, muted: nextMuted, prev: undefined, prevMuted: false })
settleTitleEnter()
}
createEffect(
on(showHeader, (show, prev) => {
if (!show) {
clearHeaderAnim()
return
}
if (show === prev) return
animateHeader()
}),
)
createEffect(
on(
() => [sessionKey(), headerTitle(), placeholderTitle()] as const,
([nextSession, nextTitle, nextMuted]) => {
// new session — snap immediately
if (nextSession !== headerText.session) {
setHeaderText("session", nextSession)
if (nextTitle && nextMuted) {
fadeInTitle(nextTitle, nextMuted)
} else {
snapTitle(nextTitle, nextMuted)
}
return
}
if (nextTitle === headerText.value && nextMuted === headerText.muted) {
return
}
if (!nextTitle) {
snapTitle(undefined, false)
return
}
// first title appearing
if (!headerText.value) {
fadeInTitle(nextTitle, nextMuted)
return
}
// manual rename — snap
if (title.saving || title.editing) {
snapTitle(nextTitle, nextMuted)
return
}
// normal swap — crossfade
crossfadeTitle(nextTitle, nextMuted)
},
),
)
onCleanup(() => {
clearHeaderAnim()
clearTitleAnims()
})
const toastError = (err: unknown) => errorMessage(err, language.t("common.requestFailed"))
createEffect(
on(
sessionKey,
@@ -195,7 +544,7 @@ export function MessageTimeline(props: {
setTitle("saving", false)
showToast({
title: language.t("common.requestFailed"),
description: errorMessage(err),
description: toastError(err),
})
})
}
@@ -235,7 +584,7 @@ export function MessageTimeline(props: {
.catch((err) => {
showToast({
title: language.t("common.requestFailed"),
description: errorMessage(err),
description: toastError(err),
})
})
}
@@ -254,7 +603,7 @@ export function MessageTimeline(props: {
.catch((err) => {
showToast({
title: language.t("session.delete.failed.title"),
description: errorMessage(err),
description: toastError(err),
})
return false
})
@@ -343,8 +692,10 @@ export function MessageTimeline(props: {
<div
class="absolute left-1/2 -translate-x-1/2 bottom-6 z-[60] pointer-events-none transition-all duration-200 ease-out"
classList={{
"opacity-100 translate-y-0 scale-100": props.scroll.overflow && !props.scroll.bottom,
"opacity-0 translate-y-2 scale-95 pointer-events-none": !props.scroll.overflow || props.scroll.bottom,
"opacity-100 translate-y-0 scale-100":
props.scroll.overflow && !props.scroll.bottom && !staging.isStaging(),
"opacity-0 translate-y-2 scale-95 pointer-events-none":
!props.scroll.overflow || props.scroll.bottom || staging.isStaging(),
}}
>
<button
@@ -354,88 +705,54 @@ export function MessageTimeline(props: {
<Icon name="arrow-down-to-line" />
</button>
</div>
<ScrollView
viewportRef={props.setScrollRef}
onWheel={(e) => {
const root = e.currentTarget
const delta = normalizeWheelDelta({
deltaY: e.deltaY,
deltaMode: e.deltaMode,
rootHeight: root.clientHeight,
})
if (!delta) return
markBoundaryGesture({ root, target: e.target, delta, onMarkScrollGesture: props.onMarkScrollGesture })
}}
onTouchStart={(e) => {
touchGesture = e.touches[0]?.clientY
}}
onTouchMove={(e) => {
const next = e.touches[0]?.clientY
const prev = touchGesture
touchGesture = next
if (next === undefined || prev === undefined) return
const delta = prev - next
if (!delta) return
const root = e.currentTarget
markBoundaryGesture({ root, target: e.target, delta, onMarkScrollGesture: props.onMarkScrollGesture })
}}
onTouchEnd={() => {
touchGesture = undefined
}}
onTouchCancel={() => {
touchGesture = undefined
}}
onPointerDown={(e) => {
if (e.target !== e.currentTarget) return
props.onMarkScrollGesture(e.currentTarget)
}}
onScroll={(e) => {
props.onScheduleScrollState(e.currentTarget)
if (!props.hasScrollGesture()) return
props.onAutoScrollHandleScroll()
props.onMarkScrollGesture(e.currentTarget)
if (props.isDesktop) props.onScrollSpyScroll()
}}
onClick={props.onAutoScrollInteraction}
class="relative min-w-0 w-full h-full"
style={{
"--session-title-height": showHeader() ? "40px" : "0px",
"--sticky-accordion-top": showHeader() ? "48px" : "0px",
}}
>
<Show when={showHeader()}>
<Show when={showHeader()}>
<div
data-session-title
ref={(el) => {
headerRef = el
el.style.opacity = "0"
}}
class="pointer-events-none absolute inset-x-0 top-0 z-30"
>
<div
data-session-title
classList={{
"sticky top-0 z-30 bg-[linear-gradient(to_bottom,var(--background-stronger)_48px,transparent)]": true,
"bg-[linear-gradient(to_bottom,var(--background-stronger)_38px,transparent)]": true,
"w-full": true,
"pb-4": true,
"pb-10": true,
"pl-2 pr-3 md:pl-4 md:pr-3": true,
"md:max-w-200 md:mx-auto 2xl:max-w-[1000px]": props.centered,
}}
>
<div class="h-12 w-full flex items-center justify-between gap-2">
<div class="pointer-events-auto h-12 w-full flex items-center justify-between gap-2">
<div class="flex items-center gap-1 min-w-0 flex-1 pr-3">
<Show when={parentID()}>
<IconButton
tabIndex={-1}
icon="arrow-left"
variant="ghost"
onClick={navigateParent}
aria-label={language.t("common.goBack")}
/>
<div>
<IconButton
tabIndex={-1}
icon="arrow-left"
variant="ghost"
onClick={navigateParent}
aria-label={language.t("common.goBack")}
/>
</div>
</Show>
<Show when={titleValue() || title.editing}>
<Show when={!!headerText.value || title.editing}>
<Show
when={title.editing}
fallback={
<h1
class="text-14-medium text-text-strong truncate grow-1 min-w-0 pl-2"
onDblClick={openTitleEditor}
>
{titleValue()}
<h1 class="text-14-medium text-text-strong grow-1 min-w-0 pl-2" onDblClick={openTitleEditor}>
<span class="grid min-w-0" style={{ overflow: "clip" }}>
<span ref={enterRef} class="col-start-1 row-start-1 min-w-0 truncate">
<span classList={{ "opacity-60": headerText.muted }}>{headerText.value}</span>
</span>
<span
ref={leaveRef}
class="col-start-1 row-start-1 min-w-0 truncate pointer-events-none"
style={{ opacity: "0" }}
>
<span classList={{ "opacity-60": headerText.prevMuted }}>{headerText.prev}</span>
</span>
</span>
</h1>
}
>
@@ -517,104 +834,194 @@ export function MessageTimeline(props: {
</Show>
</div>
</div>
</Show>
</div>
</Show>
<ScrollView
viewportRef={props.setScrollRef}
onWheel={(e) => {
const root = e.currentTarget
const delta = normalizeWheelDelta({
deltaY: e.deltaY,
deltaMode: e.deltaMode,
rootHeight: root.clientHeight,
})
if (!delta) return
markBoundaryGesture({ root, target: e.target, delta, onMarkScrollGesture: props.onMarkScrollGesture })
}}
onTouchStart={(e) => {
touchGesture = e.touches[0]?.clientY
}}
onTouchMove={(e) => {
const next = e.touches[0]?.clientY
const prev = touchGesture
touchGesture = next
if (next === undefined || prev === undefined) return
<div
ref={props.setContentRef}
role="log"
class="flex flex-col gap-12 items-start justify-start pb-16 transition-[margin]"
classList={{
"w-full": true,
"md:max-w-200 md:mx-auto 2xl:max-w-[1000px]": props.centered,
"mt-0.5": props.centered,
"mt-0": !props.centered,
}}
>
<Show when={props.turnStart > 0}>
<div class="w-full flex justify-center">
<Button variant="ghost" size="large" class="text-12-medium opacity-50" onClick={props.onRenderEarlier}>
{language.t("session.messages.renderEarlier")}
</Button>
</div>
</Show>
<Show when={props.historyMore}>
<div class="w-full flex justify-center">
<Button
variant="ghost"
size="large"
class="text-12-medium opacity-50"
disabled={props.historyLoading}
onClick={props.onLoadEarlier}
>
{props.historyLoading
? language.t("session.messages.loadingEarlier")
: language.t("session.messages.loadEarlier")}
</Button>
</div>
</Show>
<For each={props.renderedUserMessages}>
{(message) => {
const comments = createMemo(() => messageComments(sync.data.part[message.id] ?? []))
return (
<div
id={props.anchor(message.id)}
data-message-id={message.id}
ref={(el) => {
props.onRegisterMessage(el, message.id)
onCleanup(() => props.onUnregisterMessage(message.id))
}}
classList={{
"min-w-0 w-full max-w-full": true,
"md:max-w-200 2xl:max-w-[1000px]": props.centered,
}}
const delta = prev - next
if (!delta) return
const root = e.currentTarget
markBoundaryGesture({ root, target: e.target, delta, onMarkScrollGesture: props.onMarkScrollGesture })
}}
onTouchEnd={() => {
touchGesture = undefined
}}
onTouchCancel={() => {
touchGesture = undefined
}}
onPointerDown={(e) => {
const next = trigger(e.target)
if (next) props.onPreserveScrollAnchor(next)
if (e.target !== e.currentTarget) return
props.onMarkScrollGesture(e.currentTarget)
}}
onKeyDown={(e) => {
if (e.key !== "Enter" && e.key !== " ") return
const next = trigger(e.target)
if (!next) return
props.onPreserveScrollAnchor(next)
}}
onScroll={(e) => {
props.onScheduleScrollState(e.currentTarget)
props.onTurnBackfillScroll()
if (!props.hasScrollGesture()) return
props.onAutoScrollHandleScroll()
props.onMarkScrollGesture(e.currentTarget)
if (props.isDesktop) props.onScrollSpyScroll()
}}
onClick={(e) => {
const next = trigger(e.target)
if (next) props.onPreserveScrollAnchor(next)
props.onAutoScrollInteraction(e)
}}
class="relative min-w-0 w-full h-full"
style={{
"--session-title-height": showHeader() ? "40px" : "0px",
"--sticky-accordion-top": showHeader() ? "48px" : "0px",
}}
>
<div>
<div
ref={props.setContentRef}
role="log"
class="flex flex-col gap-0 items-start justify-start pb-16 transition-[margin]"
classList={{
"w-full": true,
"md:max-w-200 md:mx-auto 2xl:max-w-[1000px]": props.centered,
"mt-0.5": props.centered,
"mt-0": !props.centered,
}}
>
<Show when={props.turnStart > 0 || props.historyMore}>
<div class="w-full flex justify-center">
<Button
variant="ghost"
size="large"
class="text-12-medium opacity-50"
disabled={props.historyLoading}
onClick={props.onLoadEarlier}
>
<Show when={comments().length > 0}>
<div class="w-full px-4 md:px-5 pb-2">
<div class="ml-auto max-w-[82%] overflow-x-auto no-scrollbar">
<div class="flex w-max min-w-full justify-end gap-2">
<For each={comments()}>
{(comment) => (
<div class="shrink-0 max-w-[260px] rounded-[6px] border border-border-weak-base bg-background-stronger px-2.5 py-2">
<div class="flex items-center gap-1.5 min-w-0 text-11-medium text-text-strong">
<FileIcon node={{ path: comment.path, type: "file" }} class="size-3.5 shrink-0" />
<span class="truncate">{getFilename(comment.path)}</span>
<Show when={comment.selection}>
{(selection) => (
<span class="shrink-0 text-text-weak">
{selection().startLine === selection().endLine
? `:${selection().startLine}`
: `:${selection().startLine}-${selection().endLine}`}
</span>
)}
</Show>
</div>
<div class="pt-1 text-12-regular text-text-strong whitespace-pre-wrap break-words">
{comment.comment}
</div>
</div>
)}
</For>
{props.historyLoading
? language.t("session.messages.loadingEarlier")
: language.t("session.messages.loadEarlier")}
</Button>
</div>
</Show>
<For each={rendered()}>
{(messageID) => {
// Capture at creation time: animate only messages added after the
// timeline finishes its initial backfill staging, plus the first
// turn while a brand new session is still using its default title.
const isNew =
staging.ready() ||
(defaultTitle() &&
sessionStatus() !== "idle" &&
props.renderedUserMessages.length === 1 &&
messageID === props.renderedUserMessages[0]?.id)
const active = createMemo(() => activeMessageID() === messageID)
const queued = createMemo(() => {
if (active()) return false
const activeID = activeMessageID()
if (activeID) return messageID > activeID
return false
})
const comments = createMemo(() => messageComments(sync.data.part[messageID] ?? []), [], {
equals: (a, b) => {
if (a.length !== b.length) return false
return a.every((x, i) => x.path === b[i].path && x.comment === b[i].comment)
},
})
const commentCount = createMemo(() => comments().length)
return (
<div
id={props.anchor(messageID)}
data-message-id={messageID}
ref={(el) => {
props.onRegisterMessage(el, messageID)
onCleanup(() => props.onUnregisterMessage(messageID))
}}
classList={{
"min-w-0 w-full max-w-full": true,
"md:max-w-200 2xl:max-w-[1000px]": props.centered,
}}
>
<Show when={commentCount() > 0}>
<div class="w-full px-4 md:px-5 pb-2">
<div class="ml-auto max-w-[82%] overflow-x-auto no-scrollbar">
<div class="flex w-max min-w-full justify-end gap-2">
<Index each={comments()}>
{(commentAccessor: () => MessageComment) => {
const comment = createMemo(() => commentAccessor())
return (
<div class="shrink-0 max-w-[260px] rounded-[6px] border border-border-weak-base bg-background-stronger px-2.5 py-2">
<div class="flex items-center gap-1.5 min-w-0 text-11-medium text-text-strong">
<FileIcon
node={{ path: comment().path, type: "file" }}
class="size-3.5 shrink-0"
/>
<span class="truncate">{getFilename(comment().path)}</span>
<Show when={comment().selection}>
{(selection) => (
<span class="shrink-0 text-text-weak">
{selection().startLine === selection().endLine
? `:${selection().startLine}`
: `:${selection().startLine}-${selection().endLine}`}
</span>
)}
</Show>
</div>
<div class="pt-1 text-12-regular text-text-strong whitespace-pre-wrap break-words">
{comment().comment}
</div>
</div>
)
}}
</Index>
</div>
</div>
</div>
</div>
</Show>
<SessionTurn
sessionID={sessionID() ?? ""}
messageID={message.id}
lastUserMessageID={props.lastUserMessageID}
showReasoningSummaries={settings.general.showReasoningSummaries()}
shellToolDefaultOpen={settings.general.shellToolPartsExpanded()}
editToolDefaultOpen={settings.general.editToolPartsExpanded()}
classes={{
root: "min-w-0 w-full relative",
content: "flex flex-col justify-between !overflow-visible",
container: "w-full px-4 md:px-5",
}}
/>
</div>
)
}}
</For>
</Show>
<SessionTurn
sessionID={sessionID() ?? ""}
messageID={messageID}
active={active()}
queued={queued()}
animate={isNew || active()}
showReasoningSummaries={settings.general.showReasoningSummaries()}
shellToolDefaultOpen={settings.general.shellToolPartsExpanded()}
editToolDefaultOpen={settings.general.editToolPartsExpanded()}
classes={{
root: "min-w-0 w-full relative",
content: "flex flex-col justify-between !overflow-visible",
container: "w-full px-4 md:px-5",
}}
/>
</div>
)
}}
</For>
</div>
</div>
</ScrollView>
</div>

View File

@@ -1,4 +1,4 @@
import { createEffect, on, onCleanup, type JSX } from "solid-js"
import { createEffect, onCleanup, type JSX } from "solid-js"
import type { FileDiff } from "@opencode-ai/sdk/v2"
import { SessionReview } from "@opencode-ai/ui/session-review"
import type {
@@ -119,32 +119,12 @@ export function SessionReviewTab(props: SessionReviewTabProps) {
})
}
createEffect(
on(
() => props.diffs().length,
() => queueRestore(),
{ defer: true },
),
)
createEffect(
on(
() => props.diffStyle,
() => queueRestore(),
{ defer: true },
),
)
createEffect(
on(
() => layout.ready(),
(ready) => {
if (!ready) return
queueRestore()
},
{ defer: true },
),
)
createEffect(() => {
props.diffs().length
props.diffStyle
if (!layout.ready()) return
queueRestore()
})
onCleanup(() => {
if (restoreFrame !== undefined) cancelAnimationFrame(restoreFrame)
@@ -176,7 +156,7 @@ export function SessionReviewTab(props: SessionReviewTabProps) {
open={props.view().review.open()}
onOpenChange={props.view().review.setOpen}
classes={{
root: props.classes?.root ?? "pb-6 pr-3",
root: props.classes?.root ?? "pr-3",
header: props.classes?.header ?? "px-3",
container: props.classes?.container ?? "pl-3",
}}

View File

@@ -56,9 +56,9 @@ export function TerminalPanel() {
on(
() => terminal.all().length,
(count, prevCount) => {
if (prevCount !== undefined && prevCount > 0 && count === 0) {
if (opened()) view().terminal.toggle()
}
if (prevCount === undefined || prevCount <= 0 || count !== 0) return
if (!opened()) return
close()
},
),
)
@@ -102,7 +102,7 @@ export function TerminalPanel() {
const all = createMemo(() => terminal.all())
const ids = createMemo(() => all().map((pty) => pty.id))
const byId = createMemo(() => new Map(all().map((pty) => [pty.id, pty])))
const byId = createMemo(() => new Map(all().map((pty) => [pty.id, { ...pty }])))
const handleTerminalDragStart = (event: unknown) => {
const id = getDraggableId(event)
@@ -189,7 +189,13 @@ export function TerminalPanel() {
>
<Tabs.List class="h-10">
<SortableProvider ids={ids()}>
<For each={all()}>{(pty) => <SortableTerminalTab terminal={pty} onClose={close} />}</For>
<For each={ids()}>
{(id) => (
<Show when={byId().get(id)}>
{(pty) => <SortableTerminalTab terminal={pty()} onClose={close} />}
</Show>
)}
</For>
</SortableProvider>
<div class="h-full flex items-center justify-center">
<TooltipKeybind

View File

@@ -1,4 +1,4 @@
import { createEffect, createMemo, on, onCleanup } from "solid-js"
import { createEffect, createMemo, onCleanup, onMount } from "solid-js"
import { UserMessage } from "@opencode-ai/sdk/v2"
export const messageIdFromHash = (hash: string) => {
@@ -19,8 +19,7 @@ export const useSessionHashScroll = (input: {
setPendingMessage: (value: string | undefined) => void
setActiveMessage: (message: UserMessage | undefined) => void
setTurnStart: (value: number) => void
scheduleTurnBackfill: () => void
autoScroll: { pause: () => void; forceScrollToBottom: () => void }
autoScroll: { pause: () => void; snapToBottom: () => void }
scroller: () => HTMLDivElement | undefined
anchor: (id: string) => string
scheduleScrollState: (el: HTMLDivElement) => void
@@ -29,6 +28,7 @@ export const useSessionHashScroll = (input: {
const visibleUserMessages = createMemo(() => input.visibleUserMessages())
const messageById = createMemo(() => new Map(visibleUserMessages().map((m) => [m.id, m])))
const messageIndex = createMemo(() => new Map(visibleUserMessages().map((m, i) => [m.id, i])))
let pendingKey = ""
const clearMessageHash = () => {
if (!window.location.hash) return
@@ -45,9 +45,10 @@ export const useSessionHashScroll = (input: {
const a = el.getBoundingClientRect()
const b = root.getBoundingClientRect()
const sticky = root.querySelector("[data-session-title]")
const inset = sticky instanceof HTMLElement ? sticky.offsetHeight : 0
const top = Math.max(0, a.top - b.top + root.scrollTop - inset)
const title = parseFloat(getComputedStyle(root).getPropertyValue("--session-title-height"))
const inset = Number.isNaN(title) ? 0 : title
// With column-reverse, scrollTop is negative — don't clamp to 0
const top = a.top - b.top + root.scrollTop - inset
root.scrollTo({ top, behavior })
return true
}
@@ -58,7 +59,6 @@ export const useSessionHashScroll = (input: {
const index = messageIndex().get(message.id) ?? -1
if (index !== -1 && index < input.turnStart()) {
input.setTurnStart(index)
input.scheduleTurnBackfill()
requestAnimationFrame(() => {
const el = document.getElementById(input.anchor(message.id))
@@ -103,7 +103,7 @@ export const useSessionHashScroll = (input: {
const applyHash = (behavior: ScrollBehavior) => {
const hash = window.location.hash.slice(1)
if (!hash) {
input.autoScroll.forceScrollToBottom()
input.autoScroll.snapToBottom()
const el = input.scroller()
if (el) input.scheduleScrollState(el)
return
@@ -127,20 +127,11 @@ export const useSessionHashScroll = (input: {
return
}
input.autoScroll.forceScrollToBottom()
input.autoScroll.snapToBottom()
const el = input.scroller()
if (el) input.scheduleScrollState(el)
}
createEffect(
on(input.sessionKey, (key) => {
if (!input.sessionID()) return
const messageID = input.consumePendingMessage(key)
if (!messageID) return
input.setPendingMessage(messageID)
}),
)
createEffect(() => {
if (!input.sessionID() || !input.messagesReady()) return
requestAnimationFrame(() => applyHash("auto"))
@@ -152,7 +143,20 @@ export const useSessionHashScroll = (input: {
visibleUserMessages()
input.turnStart()
const targetId = input.pendingMessage() ?? messageIdFromHash(window.location.hash)
let targetId = input.pendingMessage()
if (!targetId) {
const key = input.sessionKey()
if (pendingKey !== key) {
pendingKey = key
const next = input.consumePendingMessage(key)
if (next) {
input.setPendingMessage(next)
targetId = next
}
}
}
if (!targetId) targetId = messageIdFromHash(window.location.hash)
if (!targetId) return
if (input.currentMessageId() === targetId) return
@@ -164,9 +168,16 @@ export const useSessionHashScroll = (input: {
requestAnimationFrame(() => scrollToMessage(msg, "auto"))
})
createEffect(() => {
if (!input.sessionID() || !input.messagesReady()) return
const handler = () => requestAnimationFrame(() => applyHash("auto"))
onMount(() => {
if (typeof window !== "undefined" && "scrollRestoration" in window.history) {
window.history.scrollRestoration = "manual"
}
const handler = () => {
if (!input.sessionID() || !input.messagesReady()) return
requestAnimationFrame(() => applyHash("auto"))
}
window.addEventListener("hashchange", handler)
onCleanup(() => window.removeEventListener("hashchange", handler))
})

View File

@@ -1,3 +1,12 @@
export function isEditableTarget(target: EventTarget | null | undefined) {
if (!(target instanceof HTMLElement)) return false
if (/^(INPUT|TEXTAREA|SELECT|BUTTON)$/.test(target.tagName)) return true
if (target.isContentEditable) return true
if (target.closest("[contenteditable='true']")) return true
if (target.closest("input, textarea, select")) return true
return false
}
export function getCharacterOffsetInLine(lineElement: Element, targetNode: Node, offset: number): number {
const r = document.createRange()
r.selectNodeContents(lineElement)

View File

@@ -1,26 +1,27 @@
import { describe, expect, test } from "bun:test"
import { handleNotificationClick } from "./notification-click"
import { afterEach, describe, expect, test } from "bun:test"
import { handleNotificationClick, setNavigate } from "./notification-click"
describe("notification click", () => {
test("focuses and navigates when href exists", () => {
const calls: string[] = []
handleNotificationClick("/abc/session/123", {
focus: () => calls.push("focus"),
location: {
assign: (href) => calls.push(href),
},
})
expect(calls).toEqual(["focus", "/abc/session/123"])
afterEach(() => {
setNavigate(undefined as any)
})
test("only focuses when href is missing", () => {
test("navigates via registered navigate function", () => {
const calls: string[] = []
handleNotificationClick(undefined, {
focus: () => calls.push("focus"),
location: {
assign: (href) => calls.push(href),
},
})
expect(calls).toEqual(["focus"])
setNavigate((href) => calls.push(href))
handleNotificationClick("/abc/session/123")
expect(calls).toEqual(["/abc/session/123"])
})
test("does not navigate when href is missing", () => {
const calls: string[] = []
setNavigate((href) => calls.push(href))
handleNotificationClick(undefined)
expect(calls).toEqual([])
})
test("falls back to location.assign without registered navigate", () => {
handleNotificationClick("/abc/session/123")
// falls back to window.location.assign — no error thrown
})
})

View File

@@ -1,12 +1,12 @@
type WindowTarget = {
focus: () => void
location: {
assign: (href: string) => void
}
let nav: ((href: string) => void) | undefined
export const setNavigate = (fn: (href: string) => void) => {
nav = fn
}
export const handleNotificationClick = (href?: string, target: WindowTarget = window) => {
target.focus()
export const handleNotificationClick = (href?: string) => {
window.focus()
if (!href) return
target.location.assign(href)
if (nav) nav(href)
else window.location.assign(href)
}

View File

@@ -1,6 +0,0 @@
export function same<T>(a: readonly T[] | undefined, b: readonly T[] | undefined) {
if (a === b) return true
if (!a || !b) return false
if (a.length !== b.length) return false
return a.every((x, i) => x === b[i])
}

View File

@@ -19,4 +19,4 @@ export function getRelativeTime(dateString: string, t: Translate): string {
if (diffMinutes < 60) return t("common.time.minutesAgo.short", { count: diffMinutes })
if (diffHours < 24) return t("common.time.hoursAgo.short", { count: diffHours })
return t("common.time.daysAgo.short", { count: diffDays })
}
}

View File

@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/console-app",
"version": "1.2.15",
"version": "1.2.17",
"type": "module",
"license": "MIT",
"scripts": {

View File

@@ -26,6 +26,7 @@ async function getMainRoutes(): Promise<SitemapEntry[]> {
{ path: "/enterprise", priority: 0.8, changefreq: "weekly" },
{ path: "/brand", priority: 0.6, changefreq: "monthly" },
{ path: "/zen", priority: 0.8, changefreq: "weekly" },
{ path: "/go", priority: 0.8, changefreq: "weekly" },
]
for (const item of staticRoutes) {

View File

@@ -7,9 +7,21 @@ import { Font } from "@opencode-ai/ui/font"
import "@ibm/plex/css/ibm-plex.css"
import "./app.css"
import { LanguageProvider } from "~/context/language"
import { I18nProvider } from "~/context/i18n"
import { I18nProvider, useI18n } from "~/context/i18n"
import { strip } from "~/lib/language"
function AppMeta() {
const i18n = useI18n()
return (
<>
<Title>opencode</Title>
<Meta name="description" content={i18n.t("app.meta.description")} />
<Favicon />
<Font />
</>
)
}
export default function App() {
return (
<Router
@@ -19,10 +31,7 @@ export default function App() {
<LanguageProvider>
<I18nProvider>
<MetaProvider>
<Title>opencode</Title>
<Meta name="description" content="OpenCode - The open source coding agent." />
<Favicon />
<Font />
<AppMeta />
<Suspense>{props.children}</Suspense>
</MetaProvider>
</I18nProvider>

View File

@@ -0,0 +1,6 @@
<svg width="54" height="30" viewBox="0 0 54 30" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M24 30H0V0H24V6H6V24H18V18H12V12H24V30Z" fill="#F1ECEC"/>
<path d="M12 18H18V24H6V12H12V18Z" fill="#4B4646"/>
<path d="M48 12V24H36V12H48Z" fill="#4B4646"/>
<path d="M54 30H30V0H54V30ZM36 24H48V6H36V24Z" fill="#F1ECEC"/>
</svg>

After

Width:  |  Height:  |  Size: 333 B

View File

@@ -0,0 +1,6 @@
<svg width="54" height="30" viewBox="0 0 54 30" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M24 30H0V0H24V6H6V24H18V18H12V12H24V30Z" fill="#211E1E"/>
<path d="M12 18H18V24H6V12H12V18Z" fill="#CFCECD"/>
<path d="M48 12V24H36V12H48Z" fill="#CFCECD"/>
<path d="M54 30H30V0H54V30ZM36 24H48V6H36V24Z" fill="#211E1E"/>
</svg>

After

Width:  |  Height:  |  Size: 333 B

View File

@@ -36,7 +36,7 @@ const fetchSvgContent = async (svgPath: string): Promise<string> => {
}
}
export function Header(props: { zen?: boolean; hideGetStarted?: boolean }) {
export function Header(props: { zen?: boolean; go?: boolean; hideGetStarted?: boolean }) {
const navigate = useNavigate()
const i18n = useI18n()
const language = useLanguage()
@@ -124,8 +124,8 @@ export function Header(props: { zen?: boolean; hideGetStarted?: boolean }) {
<section data-component="top">
<div onContextMenu={handleLogoContextMenu}>
<A href={language.route("/")}>
<img data-slot="logo light" src={logoLight} alt="OpenCode" width="189" height="34" />
<img data-slot="logo dark" src={logoDark} alt="OpenCode" width="189" height="34" />
<img data-slot="logo light" src={logoLight} alt={i18n.t("nav.logoAlt")} width="189" height="34" />
<img data-slot="logo dark" src={logoDark} alt={i18n.t("nav.logoAlt")} width="189" height="34" />
</A>
</div>
@@ -161,19 +161,24 @@ export function Header(props: { zen?: boolean; hideGetStarted?: boolean }) {
<li>
<a href={language.route("/docs")}>{i18n.t("nav.docs")}</a>
</li>
<Show when={!props.zen}>
<li>
<A href={language.route("/zen")}>{i18n.t("nav.zen")}</A>
</li>
</Show>
<Show when={!props.go}>
<li>
<A href={language.route("/go")}>{i18n.t("nav.go")}</A>
</li>
</Show>
<li>
<A href={language.route("/enterprise")}>{i18n.t("nav.enterprise")}</A>
</li>
<li>
<Switch>
<Match when={props.zen}>
<a href="/auth">{i18n.t("nav.login")}</a>
</Match>
<Match when={!props.zen}>
<A href={language.route("/zen")}>{i18n.t("nav.zen")}</A>
</Match>
</Switch>
</li>
<Show when={props.zen || props.go}>
<li>
<a href="/auth">{i18n.t("nav.login")}</a>
</li>
</Show>
<Show when={!props.hideGetStarted}>
<li>
<A href={language.route("/download")} data-slot="cta-button">
@@ -257,19 +262,24 @@ export function Header(props: { zen?: boolean; hideGetStarted?: boolean }) {
<li>
<a href={language.route("/docs")}>{i18n.t("nav.docs")}</a>
</li>
<Show when={!props.zen}>
<li>
<A href={language.route("/zen")}>{i18n.t("nav.zen")}</A>
</li>
</Show>
<Show when={!props.go}>
<li>
<A href={language.route("/go")}>{i18n.t("nav.go")}</A>
</li>
</Show>
<li>
<A href={language.route("/enterprise")}>{i18n.t("nav.enterprise")}</A>
</li>
<li>
<Switch>
<Match when={props.zen}>
<a href="/auth">{i18n.t("nav.login")}</a>
</Match>
<Match when={!props.zen}>
<A href={language.route("/zen")}>{i18n.t("nav.zen")}</A>
</Match>
</Switch>
</li>
<Show when={props.zen || props.go}>
<li>
<a href="/auth">{i18n.t("nav.login")}</a>
</li>
</Show>
<Show when={!props.hideGetStarted}>
<li>
<A href={language.route("/download")} data-slot="cta-button">

View File

@@ -15,6 +15,7 @@ export const dict = {
"nav.home": "الرئيسية",
"nav.openMenu": "فتح القائمة",
"nav.getStartedFree": "ابدأ مجانا",
"nav.logoAlt": "OpenCode",
"nav.context.copyLogo": "نسخ الشعار كـ SVG",
"nav.context.copyWordmark": "نسخ اسم العلامة كـ SVG",
@@ -42,9 +43,13 @@ export const dict = {
"notFound.docs": "الوثائق",
"notFound.github": "GitHub",
"notFound.discord": "Discord",
"notFound.logoLightAlt": "شعار opencode الفاتح",
"notFound.logoDarkAlt": "شعار opencode الداكن",
"user.logout": "تسجيل الخروج",
"auth.callback.error.codeMissing": "لم يتم العثور على رمز التفويض.",
"workspace.select": "اختر مساحة العمل",
"workspace.createNew": "+ إنشاء مساحة عمل جديدة",
"workspace.modal.title": "إنشاء مساحة عمل جديدة",
@@ -76,6 +81,8 @@ export const dict = {
"error.reloadAmountMin": "يجب أن يكون مبلغ الشحن ${{amount}} على الأقل",
"error.reloadTriggerMin": "يجب أن يكون حد الرصيد ${{amount}} على الأقل",
"app.meta.description": "OpenCode - وكيل البرمجة مفتوح المصدر.",
"home.title": "OpenCode | وكيل برمجة بالذكاء الاصطناعي مفتوح المصدر",
"temp.title": "opencode | وكيل برمجة بالذكاء الاصطناعي مبني للطرفية",
@@ -91,6 +98,8 @@ export const dict = {
"temp.feature.models.afterLink": "، بما في ذلك النماذج المحلية",
"temp.screenshot.caption": "واجهة OpenCode الطرفية مع سمة tokyonight",
"temp.screenshot.alt": "واجهة OpenCode الطرفية بسمة tokyonight",
"temp.logoLightAlt": "شعار opencode الفاتح",
"temp.logoDarkAlt": "شعار opencode الداكن",
"home.banner.badge": "جديد",
"home.banner.text": "تطبيق سطح المكتب متاح بنسخة تجريبية",
@@ -238,6 +247,24 @@ export const dict = {
"تتم استضافة جميع نماذج Zen في الولايات المتحدة. يتبع المزودون سياسة عدم الاحتفاظ بالبيانات ولا يستخدمون بياناتك لتدريب النماذج، مع",
"zen.privacy.exceptionsLink": "الاستثناءات التالية",
"zen.api.error.rateLimitExceeded": "تم تجاوز حد الطلبات. يرجى المحاولة مرة أخرى لاحقًا.",
"zen.api.error.modelNotSupported": "النموذج {{model}} غير مدعوم",
"zen.api.error.modelFormatNotSupported": "النموذج {{model}} غير مدعوم للتنسيق {{format}}",
"zen.api.error.noProviderAvailable": "لا يوجد مزود متاح",
"zen.api.error.providerNotSupported": "المزود {{provider}} غير مدعوم",
"zen.api.error.missingApiKey": "مفتاح API مفقود.",
"zen.api.error.invalidApiKey": "مفتاح API غير صالح.",
"zen.api.error.subscriptionQuotaExceeded": "تم تجاوز حصة الاشتراك. أعد المحاولة خلال {{retryIn}}.",
"zen.api.error.subscriptionQuotaExceededUseFreeModels":
"تم تجاوز حصة الاشتراك. يمكنك الاستمرار في استخدام النماذج المجانية.",
"zen.api.error.noPaymentMethod": "لا توجد طريقة دفع. أضف طريقة دفع هنا: {{billingUrl}}",
"zen.api.error.insufficientBalance": "رصيد غير كاف. إدارة فواتيرك هنا: {{billingUrl}}",
"zen.api.error.workspaceMonthlyLimitReached":
"وصلت مساحة العمل الخاصة بك إلى حد الإنفاق الشهري البالغ ${{amount}}. إدارة حدودك هنا: {{billingUrl}}",
"zen.api.error.userMonthlyLimitReached":
"لقد وصلت إلى حد الإنفاق الشهري البالغ ${{amount}}. إدارة حدودك هنا: {{membersUrl}}",
"zen.api.error.modelDisabled": "النموذج معطل",
"black.meta.title": "OpenCode Black | الوصول إلى أفضل نماذج البرمجة في العالم",
"black.meta.description": "احصل على وصول إلى Claude، GPT، Gemini والمزيد مع خطط اشتراك OpenCode Black.",
"black.hero.title": "الوصول إلى أفضل نماذج البرمجة في العالم",
@@ -446,6 +473,7 @@ export const dict = {
"workspace.reload.updatePaymentMethod": "يرجى تحديث طريقة الدفع والمحاولة مرة أخرى.",
"workspace.reload.retrying": "جارٍ إعادة المحاولة...",
"workspace.reload.retry": "أعد المحاولة",
"workspace.reload.error.paymentFailed": "فشلت عملية الدفع.",
"workspace.payments.title": "سجل المدفوعات",
"workspace.payments.subtitle": "معاملات الدفع الأخيرة.",
@@ -563,6 +591,10 @@ export const dict = {
"enterprise.form.send": "إرسال",
"enterprise.form.sending": "جارٍ الإرسال...",
"enterprise.form.success": "تم إرسال الرسالة، سنتواصل معك قريبًا.",
"enterprise.form.success.submitted": "تم إرسال النموذج بنجاح.",
"enterprise.form.error.allFieldsRequired": "جميع الحقول مطلوبة.",
"enterprise.form.error.invalidEmailFormat": "تنسيق البريد الإلكتروني غير صالح.",
"enterprise.form.error.internalServer": "خطأ داخلي في الخادم.",
"enterprise.faq.title": "الأسئلة الشائعة",
"enterprise.faq.q1": "ما هو OpenCode Enterprise؟",
"enterprise.faq.a1":
@@ -595,6 +627,7 @@ export const dict = {
"bench.list.table.agent": "الوكيل",
"bench.list.table.model": "النموذج",
"bench.list.table.score": "الدرجة",
"bench.submission.error.allFieldsRequired": "جميع الحقول مطلوبة.",
"bench.detail.title": "المعيار - {{task}}",
"bench.detail.notFound": "المهمة غير موجودة",

View File

@@ -15,6 +15,7 @@ export const dict = {
"nav.home": "Início",
"nav.openMenu": "Abrir menu",
"nav.getStartedFree": "Começar grátis",
"nav.logoAlt": "OpenCode",
"nav.context.copyLogo": "Copiar logo como SVG",
"nav.context.copyWordmark": "Copiar marca como SVG",
@@ -42,9 +43,13 @@ export const dict = {
"notFound.docs": "Documentação",
"notFound.github": "GitHub",
"notFound.discord": "Discord",
"notFound.logoLightAlt": "logo opencode claro",
"notFound.logoDarkAlt": "logo opencode escuro",
"user.logout": "Sair",
"auth.callback.error.codeMissing": "Nenhum código de autorização encontrado.",
"workspace.select": "Selecionar workspace",
"workspace.createNew": "+ Criar novo workspace",
"workspace.modal.title": "Criar novo workspace",
@@ -76,6 +81,8 @@ export const dict = {
"error.reloadAmountMin": "O valor de recarga deve ser de pelo menos ${{amount}}",
"error.reloadTriggerMin": "O gatilho de saldo deve ser de pelo menos ${{amount}}",
"app.meta.description": "OpenCode - O agente de codificação de código aberto.",
"home.title": "OpenCode | O agente de codificação de código aberto com IA",
"temp.title": "opencode | Agente de codificação com IA feito para o terminal",
@@ -91,6 +98,8 @@ export const dict = {
"temp.feature.models.afterLink": ", incluindo modelos locais",
"temp.screenshot.caption": "OpenCode TUI com o tema tokyonight",
"temp.screenshot.alt": "OpenCode TUI com tema tokyonight",
"temp.logoLightAlt": "logo opencode claro",
"temp.logoDarkAlt": "logo opencode escuro",
"home.banner.badge": "Novo",
"home.banner.text": "App desktop disponível em beta",
@@ -242,6 +251,24 @@ export const dict = {
"Todos os modelos Zen são hospedados nos EUA. Os provedores seguem uma política de retenção zero e não usam seus dados para treinamento de modelo, com as",
"zen.privacy.exceptionsLink": "seguintes exceções",
"zen.api.error.rateLimitExceeded": "Limite de taxa excedido. Por favor, tente novamente mais tarde.",
"zen.api.error.modelNotSupported": "Modelo {{model}} não suportado",
"zen.api.error.modelFormatNotSupported": "Modelo {{model}} não suportado para o formato {{format}}",
"zen.api.error.noProviderAvailable": "Nenhum provedor disponível",
"zen.api.error.providerNotSupported": "Provedor {{provider}} não suportado",
"zen.api.error.missingApiKey": "Chave de API ausente.",
"zen.api.error.invalidApiKey": "Chave de API inválida.",
"zen.api.error.subscriptionQuotaExceeded": "Cota de assinatura excedida. Tente novamente em {{retryIn}}.",
"zen.api.error.subscriptionQuotaExceededUseFreeModels":
"Cota de assinatura excedida. Você pode continuar usando modelos gratuitos.",
"zen.api.error.noPaymentMethod": "Nenhuma forma de pagamento. Adicione uma forma de pagamento aqui: {{billingUrl}}",
"zen.api.error.insufficientBalance": "Saldo insuficiente. Gerencie seu faturamento aqui: {{billingUrl}}",
"zen.api.error.workspaceMonthlyLimitReached":
"Seu workspace atingiu o limite de gastos mensais de ${{amount}}. Gerencie seus limites aqui: {{billingUrl}}",
"zen.api.error.userMonthlyLimitReached":
"Você atingiu seu limite de gastos mensais de ${{amount}}. Gerencie seus limites aqui: {{membersUrl}}",
"zen.api.error.modelDisabled": "O modelo está desabilitado",
"black.meta.title": "OpenCode Black | Acesse os melhores modelos de codificação do mundo",
"black.meta.description": "Tenha acesso ao Claude, GPT, Gemini e mais com os planos de assinatura OpenCode Black.",
"black.hero.title": "Acesse os melhores modelos de codificação do mundo",
@@ -451,6 +478,7 @@ export const dict = {
"workspace.reload.updatePaymentMethod": "Por favor, atualize sua forma de pagamento e tente novamente.",
"workspace.reload.retrying": "Tentando novamente...",
"workspace.reload.retry": "Tentar novamente",
"workspace.reload.error.paymentFailed": "Pagamento falhou.",
"workspace.payments.title": "Histórico de Pagamentos",
"workspace.payments.subtitle": "Transações de pagamento recentes.",
@@ -571,6 +599,10 @@ export const dict = {
"enterprise.form.send": "Enviar",
"enterprise.form.sending": "Enviando...",
"enterprise.form.success": "Mensagem enviada, entraremos em contato em breve.",
"enterprise.form.success.submitted": "Formulário enviado com sucesso.",
"enterprise.form.error.allFieldsRequired": "Todos os campos são obrigatórios.",
"enterprise.form.error.invalidEmailFormat": "Formato de e-mail inválido.",
"enterprise.form.error.internalServer": "Erro interno do servidor.",
"enterprise.faq.title": "FAQ",
"enterprise.faq.q1": "O que é OpenCode Enterprise?",
"enterprise.faq.a1":
@@ -603,6 +635,7 @@ export const dict = {
"bench.list.table.agent": "Agente",
"bench.list.table.model": "Modelo",
"bench.list.table.score": "Pontuação",
"bench.submission.error.allFieldsRequired": "Todos os campos são obrigatórios.",
"bench.detail.title": "Benchmark - {{task}}",
"bench.detail.notFound": "Tarefa não encontrada",

View File

@@ -15,6 +15,7 @@ export const dict = {
"nav.home": "Hjem",
"nav.openMenu": "Åbn menu",
"nav.getStartedFree": "Kom i gang gratis",
"nav.logoAlt": "OpenCode",
"nav.context.copyLogo": "Kopier logo som SVG",
"nav.context.copyWordmark": "Kopier wordmark som SVG",
@@ -42,9 +43,13 @@ export const dict = {
"notFound.docs": "Dokumentation",
"notFound.github": "GitHub",
"notFound.discord": "Discord",
"notFound.logoLightAlt": "opencode logo light",
"notFound.logoDarkAlt": "opencode logo dark",
"user.logout": "Log ud",
"auth.callback.error.codeMissing": "Ingen autorisationskode fundet.",
"workspace.select": "Vælg workspace",
"workspace.createNew": "+ Opret nyt workspace",
"workspace.modal.title": "Opret nyt workspace",
@@ -76,6 +81,8 @@ export const dict = {
"error.reloadAmountMin": "Genopfyldningsbeløb skal være mindst ${{amount}}",
"error.reloadTriggerMin": "Saldogrænse skal være mindst ${{amount}}",
"app.meta.description": "OpenCode - Den open source kodningsagent.",
"home.title": "OpenCode | Den open source AI-kodningsagent",
"temp.title": "opencode | AI-kodningsagent bygget til terminalen",
@@ -91,6 +98,8 @@ export const dict = {
"temp.feature.models.afterLink": ", inklusive lokale modeller",
"temp.screenshot.caption": "opencode TUI med tokyonight-temaet",
"temp.screenshot.alt": "opencode TUI med tokyonight-temaet",
"temp.logoLightAlt": "opencode logo light",
"temp.logoDarkAlt": "opencode logo dark",
"home.banner.badge": "Ny",
"home.banner.text": "Desktop-app tilgængelig i beta",
@@ -240,6 +249,24 @@ export const dict = {
"Alle Zen-modeller er hostet i USA. Udbydere følger en nulopbevaringspolitik og bruger ikke dine data til modeltræning med",
"zen.privacy.exceptionsLink": "følgende undtagelser",
"zen.api.error.rateLimitExceeded": "Hastighedsgrænse overskredet. Prøv venligst igen senere.",
"zen.api.error.modelNotSupported": "Model {{model}} understøttes ikke",
"zen.api.error.modelFormatNotSupported": "Model {{model}} understøttes ikke for format {{format}}",
"zen.api.error.noProviderAvailable": "Ingen udbyder tilgængelig",
"zen.api.error.providerNotSupported": "Udbyder {{provider}} understøttes ikke",
"zen.api.error.missingApiKey": "Manglende API-nøgle.",
"zen.api.error.invalidApiKey": "Ugyldig API-nøgle.",
"zen.api.error.subscriptionQuotaExceeded": "Abonnementskvote overskredet. Prøv igen om {{retryIn}}.",
"zen.api.error.subscriptionQuotaExceededUseFreeModels":
"Abonnementskvote overskredet. Du kan fortsætte med at bruge gratis modeller.",
"zen.api.error.noPaymentMethod": "Ingen betalingsmetode. Tilføj en betalingsmetode her: {{billingUrl}}",
"zen.api.error.insufficientBalance": "Utilstrækkelig saldo. Administrer din fakturering her: {{billingUrl}}",
"zen.api.error.workspaceMonthlyLimitReached":
"Dit workspace har nået sin månedlige forbrugsgrænse på ${{amount}}. Administrer dine grænser her: {{billingUrl}}",
"zen.api.error.userMonthlyLimitReached":
"Du har nået din månedlige forbrugsgrænse på ${{amount}}. Administrer dine grænser her: {{membersUrl}}",
"zen.api.error.modelDisabled": "Modellen er deaktiveret",
"black.meta.title": "OpenCode Black | Få adgang til verdens bedste kodningsmodeller",
"black.meta.description": "Få adgang til Claude, GPT, Gemini og mere med OpenCode Black-abonnementer.",
"black.hero.title": "Få adgang til verdens bedste kodningsmodeller",
@@ -449,6 +476,7 @@ export const dict = {
"workspace.reload.updatePaymentMethod": "Opdater din betalingsmetode, og prøv igen.",
"workspace.reload.retrying": "Prøver igen...",
"workspace.reload.retry": "Prøv igen",
"workspace.reload.error.paymentFailed": "Betaling mislykkedes.",
"workspace.payments.title": "Betalingshistorik",
"workspace.payments.subtitle": "Seneste betalingstransaktioner.",
@@ -567,6 +595,10 @@ export const dict = {
"enterprise.form.send": "Send",
"enterprise.form.sending": "Sender...",
"enterprise.form.success": "Besked sendt, vi vender tilbage snart.",
"enterprise.form.success.submitted": "Formular indsendt med succes.",
"enterprise.form.error.allFieldsRequired": "Alle felter er påkrævet.",
"enterprise.form.error.invalidEmailFormat": "Ugyldigt e-mailformat.",
"enterprise.form.error.internalServer": "Intern serverfejl.",
"enterprise.faq.title": "FAQ",
"enterprise.faq.q1": "Hvad er OpenCode Enterprise?",
"enterprise.faq.a1":
@@ -599,6 +631,7 @@ export const dict = {
"bench.list.table.agent": "Agent",
"bench.list.table.model": "Model",
"bench.list.table.score": "Score",
"bench.submission.error.allFieldsRequired": "Alle felter er påkrævet.",
"bench.detail.title": "Benchmark - {{task}}",
"bench.detail.notFound": "Opgave ikke fundet",

View File

@@ -15,6 +15,7 @@ export const dict = {
"nav.home": "Startseite",
"nav.openMenu": "Menü öffnen",
"nav.getStartedFree": "Kostenlos starten",
"nav.logoAlt": "OpenCode",
"nav.context.copyLogo": "Logo als SVG kopieren",
"nav.context.copyWordmark": "Wortmarke als SVG kopieren",
@@ -42,9 +43,13 @@ export const dict = {
"notFound.docs": "Dokumentation",
"notFound.github": "GitHub",
"notFound.discord": "Discord",
"notFound.logoLightAlt": "OpenCode Logo hell",
"notFound.logoDarkAlt": "OpenCode Logo dunkel",
"user.logout": "Abmelden",
"auth.callback.error.codeMissing": "Kein Autorisierungscode gefunden.",
"workspace.select": "Workspace auswählen",
"workspace.createNew": "+ Neuen Workspace erstellen",
"workspace.modal.title": "Neuen Workspace erstellen",
@@ -76,6 +81,8 @@ export const dict = {
"error.reloadAmountMin": "Aufladebetrag muss mindestens ${{amount}} betragen",
"error.reloadTriggerMin": "Guthaben-Auslöser muss mindestens ${{amount}} betragen",
"app.meta.description": "OpenCode - Der Open-Source Coding-Agent.",
"home.title": "OpenCode | Der Open-Source AI-Coding-Agent",
"temp.title": "OpenCode | Für das Terminal gebauter AI-Coding-Agent",
@@ -91,6 +98,8 @@ export const dict = {
"temp.feature.models.afterLink": ", einschließlich lokaler Modelle",
"temp.screenshot.caption": "OpenCode TUI mit dem Tokyonight-Theme",
"temp.screenshot.alt": "OpenCode TUI mit Tokyonight-Theme",
"temp.logoLightAlt": "OpenCode Logo hell",
"temp.logoDarkAlt": "OpenCode Logo dunkel",
"home.banner.badge": "Neu",
"home.banner.text": "Desktop-App in der Beta verfügbar",
@@ -242,6 +251,24 @@ export const dict = {
"Alle Zen-Modelle werden in den USA gehostet. Anbieter folgen einer Zero-Retention-Policy und nutzen deine Daten nicht für Modelltraining, mit den",
"zen.privacy.exceptionsLink": "folgenden Ausnahmen",
"zen.api.error.rateLimitExceeded": "Ratenlimit überschritten. Bitte versuche es später erneut.",
"zen.api.error.modelNotSupported": "Modell {{model}} wird nicht unterstützt",
"zen.api.error.modelFormatNotSupported": "Modell {{model}} wird für das Format {{format}} nicht unterstützt",
"zen.api.error.noProviderAvailable": "Kein Anbieter verfügbar",
"zen.api.error.providerNotSupported": "Anbieter {{provider}} wird nicht unterstützt",
"zen.api.error.missingApiKey": "Fehlender API-Key.",
"zen.api.error.invalidApiKey": "Ungültiger API-Key.",
"zen.api.error.subscriptionQuotaExceeded": "Abonnement-Quote überschritten. Erneuter Versuch in {{retryIn}}.",
"zen.api.error.subscriptionQuotaExceededUseFreeModels":
"Abonnement-Quote überschritten. Du kannst weiterhin kostenlose Modelle nutzen.",
"zen.api.error.noPaymentMethod": "Keine Zahlungsmethode. Füge hier eine Zahlungsmethode hinzu: {{billingUrl}}",
"zen.api.error.insufficientBalance": "Unzureichendes Guthaben. Verwalte deine Abrechnung hier: {{billingUrl}}",
"zen.api.error.workspaceMonthlyLimitReached":
"Dein Workspace hat sein monatliches Ausgabenlimit von ${{amount}} erreicht. Verwalte deine Limits hier: {{billingUrl}}",
"zen.api.error.userMonthlyLimitReached":
"Du hast dein monatliches Ausgabenlimit von ${{amount}} erreicht. Verwalte deine Limits hier: {{membersUrl}}",
"zen.api.error.modelDisabled": "Modell ist deaktiviert",
"black.meta.title": "OpenCode Black | Zugriff auf die weltweit besten Coding-Modelle",
"black.meta.description": "Erhalte Zugriff auf Claude, GPT, Gemini und mehr mit OpenCode Black Abos.",
"black.hero.title": "Zugriff auf die weltweit besten Coding-Modelle",
@@ -451,6 +478,7 @@ export const dict = {
"workspace.reload.updatePaymentMethod": "Bitte aktualisiere deine Zahlungsmethode und versuche es erneut.",
"workspace.reload.retrying": "Versuche erneut...",
"workspace.reload.retry": "Erneut versuchen",
"workspace.reload.error.paymentFailed": "Zahlung fehlgeschlagen.",
"workspace.payments.title": "Zahlungshistorie",
"workspace.payments.subtitle": "Kürzliche Zahlungstransaktionen.",
@@ -571,6 +599,10 @@ export const dict = {
"enterprise.form.send": "Senden",
"enterprise.form.sending": "Sende...",
"enterprise.form.success": "Nachricht gesendet, wir melden uns bald.",
"enterprise.form.success.submitted": "Formular erfolgreich gesendet.",
"enterprise.form.error.allFieldsRequired": "Alle Felder sind erforderlich.",
"enterprise.form.error.invalidEmailFormat": "Ungültiges E-Mail-Format.",
"enterprise.form.error.internalServer": "Interner Serverfehler.",
"enterprise.faq.title": "FAQ",
"enterprise.faq.q1": "Was ist OpenCode Enterprise?",
"enterprise.faq.a1":
@@ -603,6 +635,7 @@ export const dict = {
"bench.list.table.agent": "Agent",
"bench.list.table.model": "Modell",
"bench.list.table.score": "Score",
"bench.submission.error.allFieldsRequired": "Alle Felder sind erforderlich.",
"bench.detail.title": "Benchmark - {{task}}",
"bench.detail.notFound": "Task nicht gefunden",

View File

@@ -6,11 +6,13 @@ export const dict = {
"nav.x": "X",
"nav.enterprise": "Enterprise",
"nav.zen": "Zen",
"nav.go": "Go",
"nav.login": "Login",
"nav.free": "Free",
"nav.home": "Home",
"nav.openMenu": "Open menu",
"nav.getStartedFree": "Get started for free",
"nav.logoAlt": "OpenCode",
"nav.context.copyLogo": "Copy logo as SVG",
"nav.context.copyWordmark": "Copy wordmark as SVG",
@@ -38,9 +40,13 @@ export const dict = {
"notFound.docs": "Docs",
"notFound.github": "GitHub",
"notFound.discord": "Discord",
"notFound.logoLightAlt": "opencode logo light",
"notFound.logoDarkAlt": "opencode logo dark",
"user.logout": "Logout",
"auth.callback.error.codeMissing": "No authorization code found.",
"workspace.select": "Select workspace",
"workspace.createNew": "+ Create New Workspace",
"workspace.modal.title": "Create New Workspace",
@@ -49,6 +55,7 @@ export const dict = {
"common.cancel": "Cancel",
"common.creating": "Creating...",
"common.create": "Create",
"common.contactUs": "Contact us",
"common.videoUnsupported": "Your browser does not support the video tag.",
"common.figure": "Fig {{n}}.",
@@ -72,6 +79,8 @@ export const dict = {
"error.reloadAmountMin": "Reload amount must be at least ${{amount}}",
"error.reloadTriggerMin": "Balance trigger must be at least ${{amount}}",
"app.meta.description": "OpenCode - The open source coding agent.",
"home.title": "OpenCode | The open source AI coding agent",
"temp.title": "opencode | AI coding agent built for the terminal",
@@ -87,6 +96,8 @@ export const dict = {
"temp.feature.models.afterLink": ", including local models",
"temp.screenshot.caption": "opencode TUI with the tokyonight theme",
"temp.screenshot.alt": "opencode TUI with tokyonight theme",
"temp.logoLightAlt": "opencode logo light",
"temp.logoDarkAlt": "opencode logo dark",
"home.banner.badge": "New",
"home.banner.text": "Desktop app available in beta",
@@ -234,6 +245,123 @@ export const dict = {
"All Zen models are hosted in the US. Providers follow a zero-retention policy and do not use your data for model training, with the",
"zen.privacy.exceptionsLink": "following exceptions",
"go.title": "OpenCode Go | Low cost coding models for everyone",
"go.meta.description":
"Go is a $10/month subscription with generous 5-hour request limits for GLM-5, Kimi K2.5, and MiniMax M2.5.",
"go.hero.title": "Low cost coding models for everyone",
"go.hero.body":
"Go brings agentic coding to programmers around the world. Offering generous limits and reliable access to the most capable open-source models, so you can build with powerful agents without worrying about cost or availability.",
"go.cta.start": "Subscribe to Go",
"go.cta.template": "{{text}} {{price}}",
"go.cta.text": "Subscribe to Go",
"go.cta.price": "$10/month",
"go.pricing.body": "Use with any agent. Top up credit if needed. Cancel any time.",
"go.graph.free": "Free",
"go.graph.freePill": "Big Pickle and free models",
"go.graph.go": "Go",
"go.graph.label": "Requests per 5 hour",
"go.graph.usageLimits": "Usage limits",
"go.graph.tick": "{{n}}x",
"go.graph.aria": "Requests per 5h: {{free}} vs {{go}}",
"go.testimonials.brand.zen": "Zen",
"go.testimonials.brand.go": "Go",
"go.testimonials.handle": "@OpenCode",
"go.testimonials.dax.name": "Dax Raad",
"go.testimonials.dax.title": "ex-CEO, Terminal Products",
"go.testimonials.dax.quoteAfter": "has been life changing, it's truly a no-brainer.",
"go.testimonials.jay.name": "Jay V",
"go.testimonials.jay.title": "ex-Founder, SEED, PM, Melt, Pop, Dapt, Cadmus, and ViewPoint",
"go.testimonials.jay.quoteBefore": "4 out of 5 people on our team love using",
"go.testimonials.jay.quoteAfter": ".",
"go.testimonials.adam.name": "Adam Elmore",
"go.testimonials.adam.title": "ex-Hero, AWS",
"go.testimonials.adam.quoteBefore": "I can't recommend",
"go.testimonials.adam.quoteAfter": "enough. Seriously, it's really good.",
"go.testimonials.david.name": "David Hill",
"go.testimonials.david.title": "ex-Head of Design, Laravel",
"go.testimonials.david.quoteBefore": "With",
"go.testimonials.david.quoteAfter": "I know all the models are tested and perfect for coding agents.",
"go.testimonials.frank.name": "Frank Wang",
"go.testimonials.frank.title": "ex-Intern, Nvidia (4 times)",
"go.testimonials.frank.quote": "I wish I was still at Nvidia.",
"go.problem.title": "What problem is Go solving?",
"go.problem.body":
"We're focused on bringing the OpenCode experience to as many people as possible. OpenCode Go is a low cost ($10/month) subscription designed to bring agentic coding to programmers around the world. It provides generous limits and reliable access to the most capable open source models.",
"go.problem.subtitle": " ",
"go.problem.item1": "Low cost subscription pricing",
"go.problem.item2": "Generous limits and reliable access",
"go.problem.item3": "Built for as many programmers as possible",
"go.problem.item4": "Includes GLM-5, Kimi K2.5, and MiniMax M2.5",
"go.how.title": "How Go works",
"go.how.body": "Go is a $10/month subscription you can use with OpenCode or any agent.",
"go.how.step1.title": "Create an account",
"go.how.step1.beforeLink": "follow the",
"go.how.step1.link": "setup instructions",
"go.how.step2.title": "Subscribe to Go",
"go.how.step2.link": "$10/month",
"go.how.step2.afterLink": "with generous limits",
"go.how.step3.title": "Start coding",
"go.how.step3.body": "with reliable access to open-source models",
"go.privacy.title": "Your privacy is important to us",
"go.privacy.body":
"The plan is designed primarily for international users, with models hosted in the US, EU, and Singapore for stable global access.",
"go.privacy.contactAfter": "if you have any questions.",
"go.privacy.beforeExceptions":
"Go models are hosted in the US. Providers follow a zero-retention policy and do not use your data for model training, with the",
"go.privacy.exceptionsLink": "following exceptions",
"go.faq.q1": "What is OpenCode Go?",
"go.faq.a1":
"Go is a low-cost subscription that gives you reliable access to capable open-source models for agentic coding.",
"go.faq.q2": "What models does Go include?",
"go.faq.a2": "Go includes GLM-5, Kimi K2.5, and MiniMax M2.5, with generous limits and reliable access.",
"go.faq.q3": "Is Go the same as Zen?",
"go.faq.a3":
"No. Zen is pay-as-you-go, while Go is a $10/month subscription with generous limits and reliable access to open-source models GLM-5, Kimi K2.5, and MiniMax M2.5.",
"go.faq.q4": "How much does Go cost?",
"go.faq.a4.p1.beforePricing": "Go costs",
"go.faq.a4.p1.pricingLink": "$10/month",
"go.faq.a4.p1.afterPricing": "with generous limits.",
"go.faq.a4.p2.beforeAccount": "You can manage your subscription in your",
"go.faq.a4.p2.accountLink": "account",
"go.faq.a4.p3": "Cancel any time.",
"go.faq.q5": "What about data and privacy?",
"go.faq.a5.body":
"The plan is designed primarily for international users, with models hosted in the US, EU, and Singapore for stable global access.",
"go.faq.a5.contactAfter": "if you have any questions.",
"go.faq.a5.beforeExceptions":
"Go models are hosted in the US. Providers follow a zero-retention policy and do not use your data for model training, with the",
"go.faq.a5.exceptionsLink": "following exceptions",
"go.faq.q6": "Can I top up credit?",
"go.faq.a6": "If you need more usage, you can top up credit in your account.",
"go.faq.q7": "Can I cancel?",
"go.faq.a7": "Yes, you can cancel any time.",
"go.faq.q8": "Can I use Go with other coding agents?",
"go.faq.a8": "Yes, you can use Go with any agent. Follow the setup instructions in your preferred coding agent.",
"go.faq.q9": "What is the difference between free models and Go?",
"go.faq.a9":
"Free models include Big Pickle plus promotional models available at the time, with a quota of 200 requests/day. Go includes GLM-5, Kimi K2.5, and MiniMax M2.5 with higher request quotas enforced across rolling windows (5-hour, weekly, and monthly), roughly equivalent to $12 per 5 hours, $30 per week, and $60 per month (actual request counts vary by model and usage).",
"zen.api.error.rateLimitExceeded": "Rate limit exceeded. Please try again later.",
"zen.api.error.modelNotSupported": "Model {{model}} not supported",
"zen.api.error.modelFormatNotSupported": "Model {{model}} not supported for format {{format}}",
"zen.api.error.noProviderAvailable": "No provider available",
"zen.api.error.providerNotSupported": "Provider {{provider}} not supported",
"zen.api.error.missingApiKey": "Missing API key.",
"zen.api.error.invalidApiKey": "Invalid API key.",
"zen.api.error.subscriptionQuotaExceeded": "Subscription quota exceeded. Retry in {{retryIn}}.",
"zen.api.error.subscriptionQuotaExceededUseFreeModels":
"Subscription quota exceeded. You can continue using free models.",
"zen.api.error.noPaymentMethod": "No payment method. Add a payment method here: {{billingUrl}}",
"zen.api.error.insufficientBalance": "Insufficient balance. Manage your billing here: {{billingUrl}}",
"zen.api.error.workspaceMonthlyLimitReached":
"Your workspace has reached its monthly spending limit of ${{amount}}. Manage your limits here: {{billingUrl}}",
"zen.api.error.userMonthlyLimitReached":
"You have reached your monthly spending limit of ${{amount}}. Manage your limits here: {{membersUrl}}",
"zen.api.error.modelDisabled": "Model is disabled",
"black.meta.title": "OpenCode Black | Access all the world's best coding models",
"black.meta.description": "Get access to Claude, GPT, Gemini and more with OpenCode Black subscription plans.",
"black.hero.title": "Access all the world's best coding models",
@@ -443,6 +571,7 @@ export const dict = {
"workspace.reload.updatePaymentMethod": "Please update your payment method and try again.",
"workspace.reload.retrying": "Retrying...",
"workspace.reload.retry": "Retry",
"workspace.reload.error.paymentFailed": "Payment failed.",
"workspace.payments.title": "Payments History",
"workspace.payments.subtitle": "Recent payment transactions.",
@@ -561,6 +690,10 @@ export const dict = {
"enterprise.form.send": "Send",
"enterprise.form.sending": "Sending...",
"enterprise.form.success": "Message sent, we'll be in touch soon.",
"enterprise.form.success.submitted": "Form submitted successfully.",
"enterprise.form.error.allFieldsRequired": "All fields are required.",
"enterprise.form.error.invalidEmailFormat": "Invalid email format.",
"enterprise.form.error.internalServer": "Internal server error.",
"enterprise.faq.title": "FAQ",
"enterprise.faq.q1": "What is OpenCode Enterprise?",
"enterprise.faq.a1":
@@ -593,6 +726,7 @@ export const dict = {
"bench.list.table.agent": "Agent",
"bench.list.table.model": "Model",
"bench.list.table.score": "Score",
"bench.submission.error.allFieldsRequired": "All fields are required.",
"bench.detail.title": "Benchmark - {{task}}",
"bench.detail.notFound": "Task not found",

View File

@@ -15,6 +15,7 @@ export const dict = {
"nav.home": "Inicio",
"nav.openMenu": "Abrir menú",
"nav.getStartedFree": "Empezar gratis",
"nav.logoAlt": "OpenCode",
"nav.context.copyLogo": "Copiar logo como SVG",
"nav.context.copyWordmark": "Copiar marca como SVG",
@@ -42,9 +43,13 @@ export const dict = {
"notFound.docs": "Documentación",
"notFound.github": "GitHub",
"notFound.discord": "Discord",
"notFound.logoLightAlt": "opencode logo claro",
"notFound.logoDarkAlt": "opencode logo oscuro",
"user.logout": "Cerrar sesión",
"auth.callback.error.codeMissing": "No se encontró código de autorización.",
"workspace.select": "Seleccionar espacio de trabajo",
"workspace.createNew": "+ Crear nuevo espacio de trabajo",
"workspace.modal.title": "Crear nuevo espacio de trabajo",
@@ -76,6 +81,8 @@ export const dict = {
"error.reloadAmountMin": "La cantidad de recarga debe ser al menos ${{amount}}",
"error.reloadTriggerMin": "El disparador de saldo debe ser al menos ${{amount}}",
"app.meta.description": "OpenCode - El agente de codificación de código abierto.",
"home.title": "OpenCode | El agente de codificación IA de código abierto",
"temp.title": "opencode | Agente de codificación IA creado para la terminal",
@@ -91,6 +98,8 @@ export const dict = {
"temp.feature.models.afterLink": ", incluyendo modelos locales",
"temp.screenshot.caption": "opencode TUI con el tema tokyonight",
"temp.screenshot.alt": "opencode TUI con tema tokyonight",
"temp.logoLightAlt": "logo de opencode claro",
"temp.logoDarkAlt": "logo de opencode oscuro",
"home.banner.badge": "Nuevo",
"home.banner.text": "Aplicación de escritorio disponible en beta",
@@ -243,6 +252,24 @@ export const dict = {
"Todos los modelos Zen están alojados en EE. UU. Los proveedores siguen una política de cero retención y no usan tus datos para entrenamiento de modelos, con las",
"zen.privacy.exceptionsLink": "siguientes excepciones",
"zen.api.error.rateLimitExceeded": "Límite de tasa excedido. Por favor, inténtalo de nuevo más tarde.",
"zen.api.error.modelNotSupported": "Modelo {{model}} no soportado",
"zen.api.error.modelFormatNotSupported": "Modelo {{model}} no soportado para el formato {{format}}",
"zen.api.error.noProviderAvailable": "Ningún proveedor disponible",
"zen.api.error.providerNotSupported": "Proveedor {{provider}} no soportado",
"zen.api.error.missingApiKey": "Falta la clave API.",
"zen.api.error.invalidApiKey": "Clave API inválida.",
"zen.api.error.subscriptionQuotaExceeded": "Cuota de suscripción excedida. Reintenta en {{retryIn}}.",
"zen.api.error.subscriptionQuotaExceededUseFreeModels":
"Cuota de suscripción excedida. Puedes continuar usando modelos gratuitos.",
"zen.api.error.noPaymentMethod": "Sin método de pago. Añade un método de pago aquí: {{billingUrl}}",
"zen.api.error.insufficientBalance": "Saldo insuficiente. Gestiona tu facturación aquí: {{billingUrl}}",
"zen.api.error.workspaceMonthlyLimitReached":
"Tu espacio de trabajo ha alcanzado su límite de gasto mensual de ${{amount}}. Gestiona tus límites aquí: {{billingUrl}}",
"zen.api.error.userMonthlyLimitReached":
"Has alcanzado tu límite de gasto mensual de ${{amount}}. Gestiona tus límites aquí: {{membersUrl}}",
"zen.api.error.modelDisabled": "El modelo está deshabilitado",
"black.meta.title": "OpenCode Black | Accede a los mejores modelos de codificación del mundo",
"black.meta.description": "Obtén acceso a Claude, GPT, Gemini y más con los planes de suscripción de OpenCode Black.",
"black.hero.title": "Accede a los mejores modelos de codificación del mundo",
@@ -452,6 +479,7 @@ export const dict = {
"workspace.reload.updatePaymentMethod": "Por favor actualiza tu método de pago e intenta de nuevo.",
"workspace.reload.retrying": "Reintentando...",
"workspace.reload.retry": "Reintentar",
"workspace.reload.error.paymentFailed": "El pago falló.",
"workspace.payments.title": "Historial de Pagos",
"workspace.payments.subtitle": "Transacciones de pago recientes.",
@@ -571,6 +599,10 @@ export const dict = {
"enterprise.form.send": "Enviar",
"enterprise.form.sending": "Enviando...",
"enterprise.form.success": "Mensaje enviado, estaremos en contacto pronto.",
"enterprise.form.success.submitted": "Formulario enviado con éxito.",
"enterprise.form.error.allFieldsRequired": "Todos los campos son obligatorios.",
"enterprise.form.error.invalidEmailFormat": "Formato de correo inválido.",
"enterprise.form.error.internalServer": "Error interno del servidor.",
"enterprise.faq.title": "FAQ",
"enterprise.faq.q1": "¿Qué es OpenCode Enterprise?",
"enterprise.faq.a1":
@@ -603,6 +635,7 @@ export const dict = {
"bench.list.table.agent": "Agente",
"bench.list.table.model": "Modelo",
"bench.list.table.score": "Puntuación",
"bench.submission.error.allFieldsRequired": "Todos los campos son obligatorios.",
"bench.detail.title": "Benchmark - {{task}}",
"bench.detail.notFound": "Tarea no encontrada",

Some files were not shown because too many files have changed in this diff Show More