2
0
Эх сурвалжийг харах

Merge branch 'dev' into chore/remove-bun-shell-opencode

Dax 1 сар өмнө
parent
commit
0fdceb954c
100 өөрчлөгдсөн 3044 нэмэгдсэн , 1191 устгасан
  1. 1 1
      .opencode/tool/github-pr-search.txt
  2. 2 1
      README.ar.md
  3. 2 1
      README.bn.md
  4. 3 1
      README.br.md
  5. 2 1
      README.bs.md
  6. 3 1
      README.da.md
  7. 3 1
      README.de.md
  8. 3 1
      README.es.md
  9. 3 1
      README.fr.md
  10. 2 1
      README.gr.md
  11. 3 1
      README.it.md
  12. 3 1
      README.ja.md
  13. 3 1
      README.ko.md
  14. 2 1
      README.md
  15. 3 1
      README.no.md
  16. 3 1
      README.pl.md
  17. 3 1
      README.ru.md
  18. 3 1
      README.th.md
  19. 3 1
      README.tr.md
  20. 2 1
      README.uk.md
  21. 141 0
      README.vi.md
  22. 3 1
      README.zh.md
  23. 3 1
      README.zht.md
  24. 25 23
      bun.lock
  25. 4 4
      nix/hashes.json
  26. 2 2
      package.json
  27. 16 4
      packages/app/e2e/AGENTS.md
  28. 185 51
      packages/app/e2e/actions.ts
  29. 3 3
      packages/app/e2e/app/home.spec.ts
  30. 11 8
      packages/app/e2e/app/server-default.spec.ts
  31. 0 4
      packages/app/e2e/app/titlebar-history.spec.ts
  32. 5 3
      packages/app/e2e/commands/panels.spec.ts
  33. 7 0
      packages/app/e2e/files/file-tree.spec.ts
  34. 25 7
      packages/app/e2e/fixtures.ts
  35. 4 14
      packages/app/e2e/projects/project-edit.spec.ts
  36. 1 29
      packages/app/e2e/projects/projects-close.spec.ts
  37. 57 57
      packages/app/e2e/projects/projects-switch.spec.ts
  38. 33 68
      packages/app/e2e/projects/workspace-new-session.spec.ts
  39. 6 42
      packages/app/e2e/projects/workspaces.spec.ts
  40. 35 2
      packages/app/e2e/prompt/prompt-async.spec.ts
  41. 181 0
      packages/app/e2e/prompt/prompt-history.spec.ts
  42. 62 0
      packages/app/e2e/prompt/prompt-shell.spec.ts
  43. 64 0
      packages/app/e2e/prompt/prompt-slash-share.spec.ts
  44. 2 2
      packages/app/e2e/prompt/prompt.spec.ts
  45. 2 2
      packages/app/e2e/selectors.ts
  46. 37 0
      packages/app/e2e/session/session-child-navigation.spec.ts
  47. 4 4
      packages/app/e2e/session/session-composer-dock.spec.ts
  48. 8 8
      packages/app/e2e/session/session-undo-redo.spec.ts
  49. 8 4
      packages/app/e2e/session/session.spec.ts
  50. 6 9
      packages/app/e2e/settings/settings-keybinds.spec.ts
  51. 10 7
      packages/app/e2e/sidebar/sidebar-popover-actions.spec.ts
  52. 3 4
      packages/app/e2e/sidebar/sidebar-session-links.spec.ts
  53. 8 5
      packages/app/e2e/sidebar/sidebar.spec.ts
  54. 120 0
      packages/app/e2e/terminal/terminal-tabs.spec.ts
  55. 29 1
      packages/app/e2e/utils.ts
  56. 1 1
      packages/app/package.json
  57. 5 13
      packages/app/src/components/prompt-input.tsx
  58. 55 3
      packages/app/src/components/prompt-input/submit.test.ts
  59. 1 0
      packages/app/src/components/prompt-input/submit.ts
  60. 6 1
      packages/app/src/components/session/session-header.tsx
  61. 33 27
      packages/app/src/components/session/session-new-view.tsx
  62. 2 2
      packages/app/src/components/titlebar.tsx
  63. 2 0
      packages/app/src/context/language.tsx
  64. 40 1
      packages/app/src/context/permission-auto-respond.test.ts
  65. 11 1
      packages/app/src/context/permission-auto-respond.ts
  66. 73 2
      packages/app/src/context/permission.tsx
  67. 2 0
      packages/app/src/context/sync.tsx
  68. 1 0
      packages/app/src/i18n/ar.ts
  69. 1 0
      packages/app/src/i18n/br.ts
  70. 1 0
      packages/app/src/i18n/bs.ts
  71. 1 0
      packages/app/src/i18n/da.ts
  72. 1 0
      packages/app/src/i18n/de.ts
  73. 4 1
      packages/app/src/i18n/en.ts
  74. 1 0
      packages/app/src/i18n/es.ts
  75. 1 0
      packages/app/src/i18n/fr.ts
  76. 1 0
      packages/app/src/i18n/ja.ts
  77. 1 0
      packages/app/src/i18n/ko.ts
  78. 1 0
      packages/app/src/i18n/no.ts
  79. 1 0
      packages/app/src/i18n/pl.ts
  80. 1 0
      packages/app/src/i18n/ru.ts
  81. 1 0
      packages/app/src/i18n/th.ts
  82. 1 0
      packages/app/src/i18n/tr.ts
  83. 1 0
      packages/app/src/i18n/zh.ts
  84. 1 0
      packages/app/src/i18n/zht.ts
  85. 28 0
      packages/app/src/index.css
  86. 58 25
      packages/app/src/pages/directory-layout.tsx
  87. 203 77
      packages/app/src/pages/layout.tsx
  88. 0 1
      packages/app/src/pages/layout/sidebar-items.tsx
  89. 1 18
      packages/app/src/pages/layout/sidebar-project.tsx
  90. 23 3
      packages/app/src/pages/layout/sidebar-shell.tsx
  91. 3 3
      packages/app/src/pages/layout/sidebar-workspace.tsx
  92. 131 69
      packages/app/src/pages/session.tsx
  93. 1 1
      packages/app/src/pages/session/composer/session-composer-region.tsx
  94. 2 2
      packages/app/src/pages/session/file-tabs.tsx
  95. 103 1
      packages/app/src/pages/session/helpers.ts
  96. 133 373
      packages/app/src/pages/session/message-timeline.tsx
  97. 158 0
      packages/app/src/pages/session/session-model-helpers.test.ts
  98. 48 0
      packages/app/src/pages/session/session-model-helpers.ts
  99. 217 177
      packages/app/src/pages/session/session-side-panel.tsx
  100. 522 0
      packages/app/src/pages/session/session-timeline-header.tsx

+ 1 - 1
.opencode/tool/github-pr-search.txt

@@ -1,6 +1,6 @@
 Use this tool to search GitHub pull requests by title and description.
 Use this tool to search GitHub pull requests by title and description.
 
 
-This tool searches PRs in the sst/opencode repository and returns LLM-friendly results including:
+This tool searches PRs in the anomalyco/opencode repository and returns LLM-friendly results including:
 - PR number and title
 - PR number and title
 - Author
 - Author
 - State (open/closed/merged)
 - State (open/closed/merged)

+ 2 - 1
README.ar.md

@@ -35,7 +35,8 @@
   <a href="README.tr.md">Türkçe</a> |
   <a href="README.tr.md">Türkçe</a> |
   <a href="README.uk.md">Українська</a> |
   <a href="README.uk.md">Українська</a> |
   <a href="README.bn.md">বাংলা</a> |
   <a href="README.bn.md">বাংলা</a> |
-  <a href="README.gr.md">Ελληνικά</a>
+  <a href="README.gr.md">Ελληνικά</a> |
+  <a href="README.vi.md">Tiếng Việt</a>
 </p>
 </p>
 
 
 [![OpenCode Terminal UI](packages/web/src/assets/lander/screenshot.png)](https://opencode.ai)
 [![OpenCode Terminal UI](packages/web/src/assets/lander/screenshot.png)](https://opencode.ai)

+ 2 - 1
README.bn.md

@@ -35,7 +35,8 @@
   <a href="README.tr.md">Türkçe</a> |
   <a href="README.tr.md">Türkçe</a> |
   <a href="README.uk.md">Українська</a> |
   <a href="README.uk.md">Українська</a> |
   <a href="README.bn.md">বাংলা</a> |
   <a href="README.bn.md">বাংলা</a> |
-  <a href="README.gr.md">Ελληνικά</a>
+  <a href="README.gr.md">Ελληνικά</a> |
+  <a href="README.vi.md">Tiếng Việt</a>
 </p>
 </p>
 
 
 [![OpenCode Terminal UI](packages/web/src/assets/lander/screenshot.png)](https://opencode.ai)
 [![OpenCode Terminal UI](packages/web/src/assets/lander/screenshot.png)](https://opencode.ai)

+ 3 - 1
README.br.md

@@ -27,6 +27,7 @@
   <a href="README.ja.md">日本語</a> |
   <a href="README.ja.md">日本語</a> |
   <a href="README.pl.md">Polski</a> |
   <a href="README.pl.md">Polski</a> |
   <a href="README.ru.md">Русский</a> |
   <a href="README.ru.md">Русский</a> |
+  <a href="README.bs.md">Bosanski</a> |
   <a href="README.ar.md">العربية</a> |
   <a href="README.ar.md">العربية</a> |
   <a href="README.no.md">Norsk</a> |
   <a href="README.no.md">Norsk</a> |
   <a href="README.br.md">Português (Brasil)</a> |
   <a href="README.br.md">Português (Brasil)</a> |
@@ -34,7 +35,8 @@
   <a href="README.tr.md">Türkçe</a> |
   <a href="README.tr.md">Türkçe</a> |
   <a href="README.uk.md">Українська</a> |
   <a href="README.uk.md">Українська</a> |
   <a href="README.bn.md">বাংলা</a> |
   <a href="README.bn.md">বাংলা</a> |
-  <a href="README.gr.md">Ελληνικά</a>
+  <a href="README.gr.md">Ελληνικά</a> |
+  <a href="README.vi.md">Tiếng Việt</a>
 </p>
 </p>
 
 
 [![OpenCode Terminal UI](packages/web/src/assets/lander/screenshot.png)](https://opencode.ai)
 [![OpenCode Terminal UI](packages/web/src/assets/lander/screenshot.png)](https://opencode.ai)

+ 2 - 1
README.bs.md

@@ -35,7 +35,8 @@
   <a href="README.tr.md">Türkçe</a> |
   <a href="README.tr.md">Türkçe</a> |
   <a href="README.uk.md">Українська</a> |
   <a href="README.uk.md">Українська</a> |
   <a href="README.bn.md">বাংলা</a> |
   <a href="README.bn.md">বাংলা</a> |
-  <a href="README.gr.md">Ελληνικά</a>
+  <a href="README.gr.md">Ελληνικά</a> |
+  <a href="README.vi.md">Tiếng Việt</a>
 </p>
 </p>
 
 
 [![OpenCode Terminal UI](packages/web/src/assets/lander/screenshot.png)](https://opencode.ai)
 [![OpenCode Terminal UI](packages/web/src/assets/lander/screenshot.png)](https://opencode.ai)

+ 3 - 1
README.da.md

@@ -27,6 +27,7 @@
   <a href="README.ja.md">日本語</a> |
   <a href="README.ja.md">日本語</a> |
   <a href="README.pl.md">Polski</a> |
   <a href="README.pl.md">Polski</a> |
   <a href="README.ru.md">Русский</a> |
   <a href="README.ru.md">Русский</a> |
+  <a href="README.bs.md">Bosanski</a> |
   <a href="README.ar.md">العربية</a> |
   <a href="README.ar.md">العربية</a> |
   <a href="README.no.md">Norsk</a> |
   <a href="README.no.md">Norsk</a> |
   <a href="README.br.md">Português (Brasil)</a> |
   <a href="README.br.md">Português (Brasil)</a> |
@@ -34,7 +35,8 @@
   <a href="README.tr.md">Türkçe</a> |
   <a href="README.tr.md">Türkçe</a> |
   <a href="README.uk.md">Українська</a> |
   <a href="README.uk.md">Українська</a> |
   <a href="README.bn.md">বাংলা</a> |
   <a href="README.bn.md">বাংলা</a> |
-  <a href="README.gr.md">Ελληνικά</a>
+  <a href="README.gr.md">Ελληνικά</a> |
+  <a href="README.vi.md">Tiếng Việt</a>
 </p>
 </p>
 
 
 [![OpenCode Terminal UI](packages/web/src/assets/lander/screenshot.png)](https://opencode.ai)
 [![OpenCode Terminal UI](packages/web/src/assets/lander/screenshot.png)](https://opencode.ai)

+ 3 - 1
README.de.md

@@ -27,6 +27,7 @@
   <a href="README.ja.md">日本語</a> |
   <a href="README.ja.md">日本語</a> |
   <a href="README.pl.md">Polski</a> |
   <a href="README.pl.md">Polski</a> |
   <a href="README.ru.md">Русский</a> |
   <a href="README.ru.md">Русский</a> |
+  <a href="README.bs.md">Bosanski</a> |
   <a href="README.ar.md">العربية</a> |
   <a href="README.ar.md">العربية</a> |
   <a href="README.no.md">Norsk</a> |
   <a href="README.no.md">Norsk</a> |
   <a href="README.br.md">Português (Brasil)</a> |
   <a href="README.br.md">Português (Brasil)</a> |
@@ -34,7 +35,8 @@
   <a href="README.tr.md">Türkçe</a> |
   <a href="README.tr.md">Türkçe</a> |
   <a href="README.uk.md">Українська</a> |
   <a href="README.uk.md">Українська</a> |
   <a href="README.bn.md">বাংলা</a> |
   <a href="README.bn.md">বাংলা</a> |
-  <a href="README.gr.md">Ελληνικά</a>
+  <a href="README.gr.md">Ελληνικά</a> |
+  <a href="README.vi.md">Tiếng Việt</a>
 </p>
 </p>
 
 
 [![OpenCode Terminal UI](packages/web/src/assets/lander/screenshot.png)](https://opencode.ai)
 [![OpenCode Terminal UI](packages/web/src/assets/lander/screenshot.png)](https://opencode.ai)

+ 3 - 1
README.es.md

@@ -27,6 +27,7 @@
   <a href="README.ja.md">日本語</a> |
   <a href="README.ja.md">日本語</a> |
   <a href="README.pl.md">Polski</a> |
   <a href="README.pl.md">Polski</a> |
   <a href="README.ru.md">Русский</a> |
   <a href="README.ru.md">Русский</a> |
+  <a href="README.bs.md">Bosanski</a> |
   <a href="README.ar.md">العربية</a> |
   <a href="README.ar.md">العربية</a> |
   <a href="README.no.md">Norsk</a> |
   <a href="README.no.md">Norsk</a> |
   <a href="README.br.md">Português (Brasil)</a> |
   <a href="README.br.md">Português (Brasil)</a> |
@@ -34,7 +35,8 @@
   <a href="README.tr.md">Türkçe</a> |
   <a href="README.tr.md">Türkçe</a> |
   <a href="README.uk.md">Українська</a> |
   <a href="README.uk.md">Українська</a> |
   <a href="README.bn.md">বাংলা</a> |
   <a href="README.bn.md">বাংলা</a> |
-  <a href="README.gr.md">Ελληνικά</a>
+  <a href="README.gr.md">Ελληνικά</a> |
+  <a href="README.vi.md">Tiếng Việt</a>
 </p>
 </p>
 
 
 [![OpenCode Terminal UI](packages/web/src/assets/lander/screenshot.png)](https://opencode.ai)
 [![OpenCode Terminal UI](packages/web/src/assets/lander/screenshot.png)](https://opencode.ai)

+ 3 - 1
README.fr.md

@@ -27,6 +27,7 @@
   <a href="README.ja.md">日本語</a> |
   <a href="README.ja.md">日本語</a> |
   <a href="README.pl.md">Polski</a> |
   <a href="README.pl.md">Polski</a> |
   <a href="README.ru.md">Русский</a> |
   <a href="README.ru.md">Русский</a> |
+  <a href="README.bs.md">Bosanski</a> |
   <a href="README.ar.md">العربية</a> |
   <a href="README.ar.md">العربية</a> |
   <a href="README.no.md">Norsk</a> |
   <a href="README.no.md">Norsk</a> |
   <a href="README.br.md">Português (Brasil)</a> |
   <a href="README.br.md">Português (Brasil)</a> |
@@ -34,7 +35,8 @@
   <a href="README.tr.md">Türkçe</a> |
   <a href="README.tr.md">Türkçe</a> |
   <a href="README.uk.md">Українська</a> |
   <a href="README.uk.md">Українська</a> |
   <a href="README.bn.md">বাংলা</a> |
   <a href="README.bn.md">বাংলা</a> |
-  <a href="README.gr.md">Ελληνικά</a>
+  <a href="README.gr.md">Ελληνικά</a> |
+  <a href="README.vi.md">Tiếng Việt</a>
 </p>
 </p>
 
 
 [![OpenCode Terminal UI](packages/web/src/assets/lander/screenshot.png)](https://opencode.ai)
 [![OpenCode Terminal UI](packages/web/src/assets/lander/screenshot.png)](https://opencode.ai)

+ 2 - 1
README.gr.md

@@ -35,7 +35,8 @@
   <a href="README.tr.md">Türkçe</a> |
   <a href="README.tr.md">Türkçe</a> |
   <a href="README.uk.md">Українська</a> |
   <a href="README.uk.md">Українська</a> |
   <a href="README.bn.md">বাংলা</a> |
   <a href="README.bn.md">বাংলা</a> |
-  <a href="README.gr.md">Ελληνικά</a>
+  <a href="README.gr.md">Ελληνικά</a> |
+  <a href="README.vi.md">Tiếng Việt</a>
 </p>
 </p>
 
 
 [![OpenCode Terminal UI](packages/web/src/assets/lander/screenshot.png)](https://opencode.ai)
 [![OpenCode Terminal UI](packages/web/src/assets/lander/screenshot.png)](https://opencode.ai)

+ 3 - 1
README.it.md

@@ -27,6 +27,7 @@
   <a href="README.ja.md">日本語</a> |
   <a href="README.ja.md">日本語</a> |
   <a href="README.pl.md">Polski</a> |
   <a href="README.pl.md">Polski</a> |
   <a href="README.ru.md">Русский</a> |
   <a href="README.ru.md">Русский</a> |
+  <a href="README.bs.md">Bosanski</a> |
   <a href="README.ar.md">العربية</a> |
   <a href="README.ar.md">العربية</a> |
   <a href="README.no.md">Norsk</a> |
   <a href="README.no.md">Norsk</a> |
   <a href="README.br.md">Português (Brasil)</a> |
   <a href="README.br.md">Português (Brasil)</a> |
@@ -34,7 +35,8 @@
   <a href="README.tr.md">Türkçe</a> |
   <a href="README.tr.md">Türkçe</a> |
   <a href="README.uk.md">Українська</a> |
   <a href="README.uk.md">Українська</a> |
   <a href="README.bn.md">বাংলা</a> |
   <a href="README.bn.md">বাংলা</a> |
-  <a href="README.gr.md">Ελληνικά</a>
+  <a href="README.gr.md">Ελληνικά</a> |
+  <a href="README.vi.md">Tiếng Việt</a>
 </p>
 </p>
 
 
 [![OpenCode Terminal UI](packages/web/src/assets/lander/screenshot.png)](https://opencode.ai)
 [![OpenCode Terminal UI](packages/web/src/assets/lander/screenshot.png)](https://opencode.ai)

+ 3 - 1
README.ja.md

@@ -27,6 +27,7 @@
   <a href="README.ja.md">日本語</a> |
   <a href="README.ja.md">日本語</a> |
   <a href="README.pl.md">Polski</a> |
   <a href="README.pl.md">Polski</a> |
   <a href="README.ru.md">Русский</a> |
   <a href="README.ru.md">Русский</a> |
+  <a href="README.bs.md">Bosanski</a> |
   <a href="README.ar.md">العربية</a> |
   <a href="README.ar.md">العربية</a> |
   <a href="README.no.md">Norsk</a> |
   <a href="README.no.md">Norsk</a> |
   <a href="README.br.md">Português (Brasil)</a> |
   <a href="README.br.md">Português (Brasil)</a> |
@@ -34,7 +35,8 @@
   <a href="README.tr.md">Türkçe</a> |
   <a href="README.tr.md">Türkçe</a> |
   <a href="README.uk.md">Українська</a> |
   <a href="README.uk.md">Українська</a> |
   <a href="README.bn.md">বাংলা</a> |
   <a href="README.bn.md">বাংলা</a> |
-  <a href="README.gr.md">Ελληνικά</a>
+  <a href="README.gr.md">Ελληνικά</a> |
+  <a href="README.vi.md">Tiếng Việt</a>
 </p>
 </p>
 
 
 [![OpenCode Terminal UI](packages/web/src/assets/lander/screenshot.png)](https://opencode.ai)
 [![OpenCode Terminal UI](packages/web/src/assets/lander/screenshot.png)](https://opencode.ai)

+ 3 - 1
README.ko.md

@@ -27,6 +27,7 @@
   <a href="README.ja.md">日本語</a> |
   <a href="README.ja.md">日本語</a> |
   <a href="README.pl.md">Polski</a> |
   <a href="README.pl.md">Polski</a> |
   <a href="README.ru.md">Русский</a> |
   <a href="README.ru.md">Русский</a> |
+  <a href="README.bs.md">Bosanski</a> |
   <a href="README.ar.md">العربية</a> |
   <a href="README.ar.md">العربية</a> |
   <a href="README.no.md">Norsk</a> |
   <a href="README.no.md">Norsk</a> |
   <a href="README.br.md">Português (Brasil)</a> |
   <a href="README.br.md">Português (Brasil)</a> |
@@ -34,7 +35,8 @@
   <a href="README.tr.md">Türkçe</a> |
   <a href="README.tr.md">Türkçe</a> |
   <a href="README.uk.md">Українська</a> |
   <a href="README.uk.md">Українська</a> |
   <a href="README.bn.md">বাংলা</a> |
   <a href="README.bn.md">বাংলা</a> |
-  <a href="README.gr.md">Ελληνικά</a>
+  <a href="README.gr.md">Ελληνικά</a> |
+  <a href="README.vi.md">Tiếng Việt</a>
 </p>
 </p>
 
 
 [![OpenCode Terminal UI](packages/web/src/assets/lander/screenshot.png)](https://opencode.ai)
 [![OpenCode Terminal UI](packages/web/src/assets/lander/screenshot.png)](https://opencode.ai)

+ 2 - 1
README.md

@@ -35,7 +35,8 @@
   <a href="README.tr.md">Türkçe</a> |
   <a href="README.tr.md">Türkçe</a> |
   <a href="README.uk.md">Українська</a> |
   <a href="README.uk.md">Українська</a> |
   <a href="README.bn.md">বাংলা</a> |
   <a href="README.bn.md">বাংলা</a> |
-  <a href="README.gr.md">Ελληνικά</a>
+  <a href="README.gr.md">Ελληνικά</a> |
+  <a href="README.vi.md">Tiếng Việt</a>
 </p>
 </p>
 
 
 [![OpenCode Terminal UI](packages/web/src/assets/lander/screenshot.png)](https://opencode.ai)
 [![OpenCode Terminal UI](packages/web/src/assets/lander/screenshot.png)](https://opencode.ai)

+ 3 - 1
README.no.md

@@ -27,6 +27,7 @@
   <a href="README.ja.md">日本語</a> |
   <a href="README.ja.md">日本語</a> |
   <a href="README.pl.md">Polski</a> |
   <a href="README.pl.md">Polski</a> |
   <a href="README.ru.md">Русский</a> |
   <a href="README.ru.md">Русский</a> |
+  <a href="README.bs.md">Bosanski</a> |
   <a href="README.ar.md">العربية</a> |
   <a href="README.ar.md">العربية</a> |
   <a href="README.no.md">Norsk</a> |
   <a href="README.no.md">Norsk</a> |
   <a href="README.br.md">Português (Brasil)</a> |
   <a href="README.br.md">Português (Brasil)</a> |
@@ -34,7 +35,8 @@
   <a href="README.tr.md">Türkçe</a> |
   <a href="README.tr.md">Türkçe</a> |
   <a href="README.uk.md">Українська</a> |
   <a href="README.uk.md">Українська</a> |
   <a href="README.bn.md">বাংলা</a> |
   <a href="README.bn.md">বাংলা</a> |
-  <a href="README.gr.md">Ελληνικά</a>
+  <a href="README.gr.md">Ελληνικά</a> |
+  <a href="README.vi.md">Tiếng Việt</a>
 </p>
 </p>
 
 
 [![OpenCode Terminal UI](packages/web/src/assets/lander/screenshot.png)](https://opencode.ai)
 [![OpenCode Terminal UI](packages/web/src/assets/lander/screenshot.png)](https://opencode.ai)

+ 3 - 1
README.pl.md

@@ -27,6 +27,7 @@
   <a href="README.ja.md">日本語</a> |
   <a href="README.ja.md">日本語</a> |
   <a href="README.pl.md">Polski</a> |
   <a href="README.pl.md">Polski</a> |
   <a href="README.ru.md">Русский</a> |
   <a href="README.ru.md">Русский</a> |
+  <a href="README.bs.md">Bosanski</a> |
   <a href="README.ar.md">العربية</a> |
   <a href="README.ar.md">العربية</a> |
   <a href="README.no.md">Norsk</a> |
   <a href="README.no.md">Norsk</a> |
   <a href="README.br.md">Português (Brasil)</a> |
   <a href="README.br.md">Português (Brasil)</a> |
@@ -34,7 +35,8 @@
   <a href="README.tr.md">Türkçe</a> |
   <a href="README.tr.md">Türkçe</a> |
   <a href="README.uk.md">Українська</a> |
   <a href="README.uk.md">Українська</a> |
   <a href="README.bn.md">বাংলা</a> |
   <a href="README.bn.md">বাংলা</a> |
-  <a href="README.gr.md">Ελληνικά</a>
+  <a href="README.gr.md">Ελληνικά</a> |
+  <a href="README.vi.md">Tiếng Việt</a>
 </p>
 </p>
 
 
 [![OpenCode Terminal UI](packages/web/src/assets/lander/screenshot.png)](https://opencode.ai)
 [![OpenCode Terminal UI](packages/web/src/assets/lander/screenshot.png)](https://opencode.ai)

+ 3 - 1
README.ru.md

@@ -27,6 +27,7 @@
   <a href="README.ja.md">日本語</a> |
   <a href="README.ja.md">日本語</a> |
   <a href="README.pl.md">Polski</a> |
   <a href="README.pl.md">Polski</a> |
   <a href="README.ru.md">Русский</a> |
   <a href="README.ru.md">Русский</a> |
+  <a href="README.bs.md">Bosanski</a> |
   <a href="README.ar.md">العربية</a> |
   <a href="README.ar.md">العربية</a> |
   <a href="README.no.md">Norsk</a> |
   <a href="README.no.md">Norsk</a> |
   <a href="README.br.md">Português (Brasil)</a> |
   <a href="README.br.md">Português (Brasil)</a> |
@@ -34,7 +35,8 @@
   <a href="README.tr.md">Türkçe</a> |
   <a href="README.tr.md">Türkçe</a> |
   <a href="README.uk.md">Українська</a> |
   <a href="README.uk.md">Українська</a> |
   <a href="README.bn.md">বাংলা</a> |
   <a href="README.bn.md">বাংলা</a> |
-  <a href="README.gr.md">Ελληνικά</a>
+  <a href="README.gr.md">Ελληνικά</a> |
+  <a href="README.vi.md">Tiếng Việt</a>
 </p>
 </p>
 
 
 [![OpenCode Terminal UI](packages/web/src/assets/lander/screenshot.png)](https://opencode.ai)
 [![OpenCode Terminal UI](packages/web/src/assets/lander/screenshot.png)](https://opencode.ai)

+ 3 - 1
README.th.md

@@ -27,6 +27,7 @@
   <a href="README.ja.md">日本語</a> |
   <a href="README.ja.md">日本語</a> |
   <a href="README.pl.md">Polski</a> |
   <a href="README.pl.md">Polski</a> |
   <a href="README.ru.md">Русский</a> |
   <a href="README.ru.md">Русский</a> |
+  <a href="README.bs.md">Bosanski</a> |
   <a href="README.ar.md">العربية</a> |
   <a href="README.ar.md">العربية</a> |
   <a href="README.no.md">Norsk</a> |
   <a href="README.no.md">Norsk</a> |
   <a href="README.br.md">Português (Brasil)</a> |
   <a href="README.br.md">Português (Brasil)</a> |
@@ -34,7 +35,8 @@
   <a href="README.tr.md">Türkçe</a> |
   <a href="README.tr.md">Türkçe</a> |
   <a href="README.uk.md">Українська</a> |
   <a href="README.uk.md">Українська</a> |
   <a href="README.bn.md">বাংলা</a> |
   <a href="README.bn.md">বাংলা</a> |
-  <a href="README.gr.md">Ελληνικά</a>
+  <a href="README.gr.md">Ελληνικά</a> |
+  <a href="README.vi.md">Tiếng Việt</a>
 </p>
 </p>
 
 
 [![OpenCode Terminal UI](packages/web/src/assets/lander/screenshot.png)](https://opencode.ai)
 [![OpenCode Terminal UI](packages/web/src/assets/lander/screenshot.png)](https://opencode.ai)

+ 3 - 1
README.tr.md

@@ -27,6 +27,7 @@
   <a href="README.ja.md">日本語</a> |
   <a href="README.ja.md">日本語</a> |
   <a href="README.pl.md">Polski</a> |
   <a href="README.pl.md">Polski</a> |
   <a href="README.ru.md">Русский</a> |
   <a href="README.ru.md">Русский</a> |
+  <a href="README.bs.md">Bosanski</a> |
   <a href="README.ar.md">العربية</a> |
   <a href="README.ar.md">العربية</a> |
   <a href="README.no.md">Norsk</a> |
   <a href="README.no.md">Norsk</a> |
   <a href="README.br.md">Português (Brasil)</a> |
   <a href="README.br.md">Português (Brasil)</a> |
@@ -34,7 +35,8 @@
   <a href="README.tr.md">Türkçe</a> |
   <a href="README.tr.md">Türkçe</a> |
   <a href="README.uk.md">Українська</a> |
   <a href="README.uk.md">Українська</a> |
   <a href="README.bn.md">বাংলা</a> |
   <a href="README.bn.md">বাংলা</a> |
-  <a href="README.gr.md">Ελληνικά</a>
+  <a href="README.gr.md">Ελληνικά</a> |
+  <a href="README.vi.md">Tiếng Việt</a>
 </p>
 </p>
 
 
 [![OpenCode Terminal UI](packages/web/src/assets/lander/screenshot.png)](https://opencode.ai)
 [![OpenCode Terminal UI](packages/web/src/assets/lander/screenshot.png)](https://opencode.ai)

+ 2 - 1
README.uk.md

@@ -35,7 +35,8 @@
   <a href="README.tr.md">Türkçe</a> |
   <a href="README.tr.md">Türkçe</a> |
   <a href="README.uk.md">Українська</a> |
   <a href="README.uk.md">Українська</a> |
   <a href="README.bn.md">বাংলা</a> |
   <a href="README.bn.md">বাংলা</a> |
-  <a href="README.gr.md">Ελληνικά</a>
+  <a href="README.gr.md">Ελληνικά</a> |
+  <a href="README.vi.md">Tiếng Việt</a>
 </p>
 </p>
 
 
 [![OpenCode Terminal UI](packages/web/src/assets/lander/screenshot.png)](https://opencode.ai)
 [![OpenCode Terminal UI](packages/web/src/assets/lander/screenshot.png)](https://opencode.ai)

+ 141 - 0
README.vi.md

@@ -0,0 +1,141 @@
+<p align="center">
+  <a href="https://opencode.ai">
+    <picture>
+      <source srcset="packages/console/app/src/asset/logo-ornate-dark.svg" media="(prefers-color-scheme: dark)">
+      <source srcset="packages/console/app/src/asset/logo-ornate-light.svg" media="(prefers-color-scheme: light)">
+      <img src="packages/console/app/src/asset/logo-ornate-light.svg" alt="OpenCode logo">
+    </picture>
+  </a>
+</p>
+<p align="center">Trợ lý lập trình AI mã nguồn mở.</p>
+<p align="center">
+  <a href="https://opencode.ai/discord"><img alt="Discord" src="https://img.shields.io/discord/1391832426048651334?style=flat-square&label=discord" /></a>
+  <a href="https://www.npmjs.com/package/opencode-ai"><img alt="npm" src="https://img.shields.io/npm/v/opencode-ai?style=flat-square" /></a>
+  <a href="https://github.com/anomalyco/opencode/actions/workflows/publish.yml"><img alt="Build status" src="https://img.shields.io/github/actions/workflow/status/anomalyco/opencode/publish.yml?style=flat-square&branch=dev" /></a>
+</p>
+
+<p align="center">
+  <a href="README.md">English</a> |
+  <a href="README.zh.md">简体中文</a> |
+  <a href="README.zht.md">繁體中文</a> |
+  <a href="README.ko.md">한국어</a> |
+  <a href="README.de.md">Deutsch</a> |
+  <a href="README.es.md">Español</a> |
+  <a href="README.fr.md">Français</a> |
+  <a href="README.it.md">Italiano</a> |
+  <a href="README.da.md">Dansk</a> |
+  <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> |
+  <a href="README.th.md">ไทย</a> |
+  <a href="README.tr.md">Türkçe</a> |
+  <a href="README.uk.md">Українська</a> |
+  <a href="README.bn.md">বাংলা</a> |
+  <a href="README.gr.md">Ελληνικά</a> |
+  <a href="README.vi.md">Tiếng Việt</a>
+</p>
+
+[![OpenCode Terminal UI](packages/web/src/assets/lander/screenshot.png)](https://opencode.ai)
+
+---
+
+### Cài đặt
+
+```bash
+# YOLO
+curl -fsSL https://opencode.ai/install | bash
+
+# Các trình quản lý gói (Package managers)
+npm i -g opencode-ai@latest        # hoặc bun/pnpm/yarn
+scoop install opencode             # Windows
+choco install opencode             # Windows
+brew install anomalyco/tap/opencode # macOS và Linux (khuyên dùng, luôn cập nhật)
+brew install opencode              # macOS và Linux (công thức brew chính thức, ít cập nhật hơn)
+sudo pacman -S opencode            # Arch Linux (Bản ổn định)
+paru -S opencode-bin               # Arch Linux (Bản mới nhất từ AUR)
+mise use -g opencode               # Mọi hệ điều hành
+nix run nixpkgs#opencode           # hoặc github:anomalyco/opencode cho nhánh dev mới nhất
+```
+
+> [!TIP]
+> Hãy xóa các phiên bản cũ hơn 0.1.x trước khi cài đặt.
+
+### Ứng dụng Desktop (BETA)
+
+OpenCode cũng có sẵn dưới dạng ứng dụng desktop. Tải trực tiếp từ [trang releases](https://github.com/anomalyco/opencode/releases) hoặc [opencode.ai/download](https://opencode.ai/download).
+
+| Nền tảng              | Tải xuống                             |
+| --------------------- | ------------------------------------- |
+| macOS (Apple Silicon) | `opencode-desktop-darwin-aarch64.dmg` |
+| macOS (Intel)         | `opencode-desktop-darwin-x64.dmg`     |
+| Windows               | `opencode-desktop-windows-x64.exe`    |
+| Linux                 | `.deb`, `.rpm`, hoặc AppImage         |
+
+```bash
+# macOS (Homebrew)
+brew install --cask opencode-desktop
+# Windows (Scoop)
+scoop bucket add extras; scoop install extras/opencode-desktop
+```
+
+#### Thư mục cài đặt
+
+Tập lệnh cài đặt tuân theo thứ tự ưu tiên sau cho đường dẫn cài đặt:
+
+1. `$OPENCODE_INSTALL_DIR` - Thư mục cài đặt tùy chỉnh
+2. `$XDG_BIN_DIR` - Đường dẫn tuân thủ XDG Base Directory Specification
+3. `$HOME/bin` - Thư mục nhị phân tiêu chuẩn của người dùng (nếu tồn tại hoặc có thể tạo)
+4. `$HOME/.opencode/bin` - Mặc định dự phòng
+
+```bash
+# Ví dụ
+OPENCODE_INSTALL_DIR=/usr/local/bin curl -fsSL https://opencode.ai/install | bash
+XDG_BIN_DIR=$HOME/.local/bin curl -fsSL https://opencode.ai/install | bash
+```
+
+### Agents (Đại diện)
+
+OpenCode bao gồm hai agent được tích hợp sẵn mà bạn có thể chuyển đổi bằng phím `Tab`.
+
+- **build** - Agent mặc định, có toàn quyền truy cập cho công việc lập trình
+- **plan** - Agent chỉ đọc dùng để phân tích và khám phá mã nguồn
+  - Mặc định từ chối việc chỉnh sửa tệp
+  - Hỏi quyền trước khi chạy các lệnh bash
+  - Lý tưởng để khám phá các codebase lạ hoặc lên kế hoạch thay đổi
+
+Ngoài ra còn có một subagent **general** dùng cho các tìm kiếm phức tạp và tác vụ nhiều bước.
+Agent này được sử dụng nội bộ và có thể gọi bằng cách dùng `@general` trong tin nhắn.
+
+Tìm hiểu thêm về [agents](https://opencode.ai/docs/agents).
+
+### Tài liệu
+
+Để biết thêm thông tin về cách cấu hình OpenCode, [**hãy truy cập tài liệu của chúng tôi**](https://opencode.ai/docs).
+
+### Đóng góp
+
+Nếu bạn muốn đóng góp cho OpenCode, vui lòng đọc [tài liệu hướng dẫn đóng góp](./CONTRIBUTING.md) trước khi gửi pull request.
+
+### Xây dựng trên nền tảng OpenCode
+
+Nếu bạn đang làm việc trên một dự án liên quan đến OpenCode và sử dụng "opencode" như một phần của tên dự án, ví dụ "opencode-dashboard" hoặc "opencode-mobile", vui lòng thêm một ghi chú vào README của bạn để làm rõ rằng dự án đó không được xây dựng bởi đội ngũ OpenCode và không liên kết với chúng tôi dưới bất kỳ hình thức nào.
+
+### Các câu hỏi thường gặp (FAQ)
+
+#### OpenCode khác biệt thế nào so với Claude Code?
+
+Về mặt tính năng, nó rất giống Claude Code. Dưới đây là những điểm khác biệt chính:
+
+- 100% mã nguồn mở
+- Không bị ràng buộc với bất kỳ nhà cung cấp nào. Mặc dù chúng tôi khuyên dùng các mô hình được cung cấp qua [OpenCode Zen](https://opencode.ai/zen), OpenCode có thể được sử dụng với Claude, OpenAI, Google, hoặc thậm chí các mô hình chạy cục bộ. Khi các mô hình phát triển, khoảng cách giữa chúng sẽ thu hẹp lại và giá cả sẽ giảm, vì vậy việc không phụ thuộc vào nhà cung cấp là rất quan trọng.
+- Hỗ trợ LSP ngay từ đầu
+- Tập trung vào TUI (Giao diện người dùng dòng lệnh). OpenCode được xây dựng bởi những người dùng neovim và đội ngũ tạo ra [terminal.shop](https://terminal.shop); chúng tôi sẽ đẩy giới hạn của những gì có thể làm được trên terminal lên mức tối đa.
+- Kiến trúc client/server. Chẳng hạn, điều này cho phép OpenCode chạy trên máy tính của bạn trong khi bạn điều khiển nó từ xa qua một ứng dụng di động, nghĩa là frontend TUI chỉ là một trong những client có thể dùng.
+
+---
+
+**Tham gia cộng đồng của chúng tôi** [Discord](https://discord.gg/opencode) | [X.com](https://x.com/opencode)

+ 3 - 1
README.zh.md

@@ -27,6 +27,7 @@
   <a href="README.ja.md">日本語</a> |
   <a href="README.ja.md">日本語</a> |
   <a href="README.pl.md">Polski</a> |
   <a href="README.pl.md">Polski</a> |
   <a href="README.ru.md">Русский</a> |
   <a href="README.ru.md">Русский</a> |
+  <a href="README.bs.md">Bosanski</a> |
   <a href="README.ar.md">العربية</a> |
   <a href="README.ar.md">العربية</a> |
   <a href="README.no.md">Norsk</a> |
   <a href="README.no.md">Norsk</a> |
   <a href="README.br.md">Português (Brasil)</a> |
   <a href="README.br.md">Português (Brasil)</a> |
@@ -34,7 +35,8 @@
   <a href="README.tr.md">Türkçe</a> |
   <a href="README.tr.md">Türkçe</a> |
   <a href="README.uk.md">Українська</a> |
   <a href="README.uk.md">Українська</a> |
   <a href="README.bn.md">বাংলা</a> |
   <a href="README.bn.md">বাংলা</a> |
-  <a href="README.gr.md">Ελληνικά</a>
+  <a href="README.gr.md">Ελληνικά</a> |
+  <a href="README.vi.md">Tiếng Việt</a>
 </p>
 </p>
 
 
 [![OpenCode Terminal UI](packages/web/src/assets/lander/screenshot.png)](https://opencode.ai)
 [![OpenCode Terminal UI](packages/web/src/assets/lander/screenshot.png)](https://opencode.ai)

+ 3 - 1
README.zht.md

@@ -27,6 +27,7 @@
   <a href="README.ja.md">日本語</a> |
   <a href="README.ja.md">日本語</a> |
   <a href="README.pl.md">Polski</a> |
   <a href="README.pl.md">Polski</a> |
   <a href="README.ru.md">Русский</a> |
   <a href="README.ru.md">Русский</a> |
+  <a href="README.bs.md">Bosanski</a> |
   <a href="README.ar.md">العربية</a> |
   <a href="README.ar.md">العربية</a> |
   <a href="README.no.md">Norsk</a> |
   <a href="README.no.md">Norsk</a> |
   <a href="README.br.md">Português (Brasil)</a> |
   <a href="README.br.md">Português (Brasil)</a> |
@@ -34,7 +35,8 @@
   <a href="README.tr.md">Türkçe</a> |
   <a href="README.tr.md">Türkçe</a> |
   <a href="README.uk.md">Українська</a> |
   <a href="README.uk.md">Українська</a> |
   <a href="README.bn.md">বাংলা</a> |
   <a href="README.bn.md">বাংলা</a> |
-  <a href="README.gr.md">Ελληνικά</a>
+  <a href="README.gr.md">Ελληνικά</a> |
+  <a href="README.vi.md">Tiếng Việt</a>
 </p>
 </p>
 
 
 [![OpenCode Terminal UI](packages/web/src/assets/lander/screenshot.png)](https://opencode.ai)
 [![OpenCode Terminal UI](packages/web/src/assets/lander/screenshot.png)](https://opencode.ai)

+ 25 - 23
bun.lock

@@ -26,7 +26,7 @@
     },
     },
     "packages/app": {
     "packages/app": {
       "name": "@opencode-ai/app",
       "name": "@opencode-ai/app",
-      "version": "1.2.19",
+      "version": "1.2.21",
       "dependencies": {
       "dependencies": {
         "@kobalte/core": "catalog:",
         "@kobalte/core": "catalog:",
         "@opencode-ai/sdk": "workspace:*",
         "@opencode-ai/sdk": "workspace:*",
@@ -76,7 +76,7 @@
     },
     },
     "packages/console/app": {
     "packages/console/app": {
       "name": "@opencode-ai/console-app",
       "name": "@opencode-ai/console-app",
-      "version": "1.2.19",
+      "version": "1.2.21",
       "dependencies": {
       "dependencies": {
         "@cloudflare/vite-plugin": "1.15.2",
         "@cloudflare/vite-plugin": "1.15.2",
         "@ibm/plex": "6.4.1",
         "@ibm/plex": "6.4.1",
@@ -110,7 +110,7 @@
     },
     },
     "packages/console/core": {
     "packages/console/core": {
       "name": "@opencode-ai/console-core",
       "name": "@opencode-ai/console-core",
-      "version": "1.2.19",
+      "version": "1.2.21",
       "dependencies": {
       "dependencies": {
         "@aws-sdk/client-sts": "3.782.0",
         "@aws-sdk/client-sts": "3.782.0",
         "@jsx-email/render": "1.1.1",
         "@jsx-email/render": "1.1.1",
@@ -137,7 +137,7 @@
     },
     },
     "packages/console/function": {
     "packages/console/function": {
       "name": "@opencode-ai/console-function",
       "name": "@opencode-ai/console-function",
-      "version": "1.2.19",
+      "version": "1.2.21",
       "dependencies": {
       "dependencies": {
         "@ai-sdk/anthropic": "2.0.0",
         "@ai-sdk/anthropic": "2.0.0",
         "@ai-sdk/openai": "2.0.2",
         "@ai-sdk/openai": "2.0.2",
@@ -161,7 +161,7 @@
     },
     },
     "packages/console/mail": {
     "packages/console/mail": {
       "name": "@opencode-ai/console-mail",
       "name": "@opencode-ai/console-mail",
-      "version": "1.2.19",
+      "version": "1.2.21",
       "dependencies": {
       "dependencies": {
         "@jsx-email/all": "2.2.3",
         "@jsx-email/all": "2.2.3",
         "@jsx-email/cli": "1.4.3",
         "@jsx-email/cli": "1.4.3",
@@ -185,7 +185,7 @@
     },
     },
     "packages/desktop": {
     "packages/desktop": {
       "name": "@opencode-ai/desktop",
       "name": "@opencode-ai/desktop",
-      "version": "1.2.19",
+      "version": "1.2.21",
       "dependencies": {
       "dependencies": {
         "@opencode-ai/app": "workspace:*",
         "@opencode-ai/app": "workspace:*",
         "@opencode-ai/ui": "workspace:*",
         "@opencode-ai/ui": "workspace:*",
@@ -218,7 +218,7 @@
     },
     },
     "packages/desktop-electron": {
     "packages/desktop-electron": {
       "name": "@opencode-ai/desktop-electron",
       "name": "@opencode-ai/desktop-electron",
-      "version": "1.2.19",
+      "version": "1.2.21",
       "dependencies": {
       "dependencies": {
         "@opencode-ai/app": "workspace:*",
         "@opencode-ai/app": "workspace:*",
         "@opencode-ai/ui": "workspace:*",
         "@opencode-ai/ui": "workspace:*",
@@ -248,7 +248,7 @@
     },
     },
     "packages/enterprise": {
     "packages/enterprise": {
       "name": "@opencode-ai/enterprise",
       "name": "@opencode-ai/enterprise",
-      "version": "1.2.19",
+      "version": "1.2.21",
       "dependencies": {
       "dependencies": {
         "@opencode-ai/ui": "workspace:*",
         "@opencode-ai/ui": "workspace:*",
         "@opencode-ai/util": "workspace:*",
         "@opencode-ai/util": "workspace:*",
@@ -277,7 +277,7 @@
     },
     },
     "packages/function": {
     "packages/function": {
       "name": "@opencode-ai/function",
       "name": "@opencode-ai/function",
-      "version": "1.2.19",
+      "version": "1.2.21",
       "dependencies": {
       "dependencies": {
         "@octokit/auth-app": "8.0.1",
         "@octokit/auth-app": "8.0.1",
         "@octokit/rest": "catalog:",
         "@octokit/rest": "catalog:",
@@ -293,7 +293,7 @@
     },
     },
     "packages/opencode": {
     "packages/opencode": {
       "name": "opencode",
       "name": "opencode",
-      "version": "1.2.19",
+      "version": "1.2.21",
       "bin": {
       "bin": {
         "opencode": "./bin/opencode",
         "opencode": "./bin/opencode",
       },
       },
@@ -351,7 +351,7 @@
         "clipboardy": "4.0.0",
         "clipboardy": "4.0.0",
         "decimal.js": "10.5.0",
         "decimal.js": "10.5.0",
         "diff": "catalog:",
         "diff": "catalog:",
-        "drizzle-orm": "1.0.0-beta.12-a5629fb",
+        "drizzle-orm": "1.0.0-beta.16-ea816b6",
         "fuzzysort": "3.1.0",
         "fuzzysort": "3.1.0",
         "glob": "13.0.5",
         "glob": "13.0.5",
         "google-auth-library": "10.5.0",
         "google-auth-library": "10.5.0",
@@ -399,8 +399,8 @@
         "@types/which": "3.0.4",
         "@types/which": "3.0.4",
         "@types/yargs": "17.0.33",
         "@types/yargs": "17.0.33",
         "@typescript/native-preview": "catalog:",
         "@typescript/native-preview": "catalog:",
-        "drizzle-kit": "1.0.0-beta.12-a5629fb",
-        "drizzle-orm": "1.0.0-beta.12-a5629fb",
+        "drizzle-kit": "1.0.0-beta.16-ea816b6",
+        "drizzle-orm": "1.0.0-beta.16-ea816b6",
         "typescript": "catalog:",
         "typescript": "catalog:",
         "vscode-languageserver-types": "3.17.5",
         "vscode-languageserver-types": "3.17.5",
         "why-is-node-running": "3.2.2",
         "why-is-node-running": "3.2.2",
@@ -409,7 +409,7 @@
     },
     },
     "packages/plugin": {
     "packages/plugin": {
       "name": "@opencode-ai/plugin",
       "name": "@opencode-ai/plugin",
-      "version": "1.2.19",
+      "version": "1.2.21",
       "dependencies": {
       "dependencies": {
         "@opencode-ai/sdk": "workspace:*",
         "@opencode-ai/sdk": "workspace:*",
         "zod": "catalog:",
         "zod": "catalog:",
@@ -429,7 +429,7 @@
     },
     },
     "packages/sdk/js": {
     "packages/sdk/js": {
       "name": "@opencode-ai/sdk",
       "name": "@opencode-ai/sdk",
-      "version": "1.2.19",
+      "version": "1.2.21",
       "devDependencies": {
       "devDependencies": {
         "@hey-api/openapi-ts": "0.90.10",
         "@hey-api/openapi-ts": "0.90.10",
         "@tsconfig/node22": "catalog:",
         "@tsconfig/node22": "catalog:",
@@ -440,7 +440,7 @@
     },
     },
     "packages/slack": {
     "packages/slack": {
       "name": "@opencode-ai/slack",
       "name": "@opencode-ai/slack",
-      "version": "1.2.19",
+      "version": "1.2.21",
       "dependencies": {
       "dependencies": {
         "@opencode-ai/sdk": "workspace:*",
         "@opencode-ai/sdk": "workspace:*",
         "@slack/bolt": "^3.17.1",
         "@slack/bolt": "^3.17.1",
@@ -475,7 +475,7 @@
     },
     },
     "packages/ui": {
     "packages/ui": {
       "name": "@opencode-ai/ui",
       "name": "@opencode-ai/ui",
-      "version": "1.2.19",
+      "version": "1.2.21",
       "dependencies": {
       "dependencies": {
         "@kobalte/core": "catalog:",
         "@kobalte/core": "catalog:",
         "@opencode-ai/sdk": "workspace:*",
         "@opencode-ai/sdk": "workspace:*",
@@ -521,7 +521,7 @@
     },
     },
     "packages/util": {
     "packages/util": {
       "name": "@opencode-ai/util",
       "name": "@opencode-ai/util",
-      "version": "1.2.19",
+      "version": "1.2.21",
       "dependencies": {
       "dependencies": {
         "zod": "catalog:",
         "zod": "catalog:",
       },
       },
@@ -532,7 +532,7 @@
     },
     },
     "packages/web": {
     "packages/web": {
       "name": "@opencode-ai/web",
       "name": "@opencode-ai/web",
-      "version": "1.2.19",
+      "version": "1.2.21",
       "dependencies": {
       "dependencies": {
         "@astrojs/cloudflare": "12.6.3",
         "@astrojs/cloudflare": "12.6.3",
         "@astrojs/markdown-remark": "6.3.1",
         "@astrojs/markdown-remark": "6.3.1",
@@ -601,8 +601,8 @@
     "ai": "5.0.124",
     "ai": "5.0.124",
     "diff": "8.0.2",
     "diff": "8.0.2",
     "dompurify": "3.3.1",
     "dompurify": "3.3.1",
-    "drizzle-kit": "1.0.0-beta.12-a5629fb",
-    "drizzle-orm": "1.0.0-beta.12-a5629fb",
+    "drizzle-kit": "1.0.0-beta.16-ea816b6",
+    "drizzle-orm": "1.0.0-beta.16-ea816b6",
     "fuzzysort": "3.1.0",
     "fuzzysort": "3.1.0",
     "hono": "4.10.7",
     "hono": "4.10.7",
     "hono-openapi": "1.1.2",
     "hono-openapi": "1.1.2",
@@ -2684,9 +2684,9 @@
 
 
     "dotenv-expand": ["[email protected]", "", { "dependencies": { "dotenv": "^16.4.5" } }, "sha512-zIHwmZPRshsCdpMDyVsqGmgyP0yT8GAgXUnkdAoJisxvf33k7yO6OuoKmcTGuXPWSsm8Oh88nZicRLA9Y0rUeA=="],
     "dotenv-expand": ["[email protected]", "", { "dependencies": { "dotenv": "^16.4.5" } }, "sha512-zIHwmZPRshsCdpMDyVsqGmgyP0yT8GAgXUnkdAoJisxvf33k7yO6OuoKmcTGuXPWSsm8Oh88nZicRLA9Y0rUeA=="],
 
 
-    "drizzle-kit": ["[email protected]2-a5629fb", "", { "dependencies": { "@drizzle-team/brocli": "^0.11.0", "@js-temporal/polyfill": "^0.5.1", "esbuild": "^0.25.10", "tsx": "^4.20.6" }, "bin": { "drizzle-kit": "bin.cjs" } }, "sha512-l+p4QOMvPGYBYEE9NBlU7diu+NSlxuOUwi0I7i01Uj1PpfU0NxhPzaks/9q1MDw4FAPP8vdD0dOhoqosKtRWWQ=="],
+    "drizzle-kit": ["[email protected]6-ea816b6", "", { "dependencies": { "@drizzle-team/brocli": "^0.11.0", "@js-temporal/polyfill": "^0.5.1", "esbuild": "^0.25.10", "jiti": "^2.6.1" }, "bin": { "drizzle-kit": "bin.cjs" } }, "sha512-GiJQqCNPZP8Kk+i7/sFa3rtXbq26tLDNi3LbMx9aoLuwF2ofk8CS7cySUGdI+r4J3q0a568quC8FZeaFTCw4IA=="],
 
 
-    "drizzle-orm": ["[email protected]2-a5629fb", "", { "peerDependencies": { "@aws-sdk/client-rds-data": ">=3", "@cloudflare/workers-types": ">=4", "@effect/sql": "^0.48.5", "@effect/sql-pg": "^0.49.7", "@electric-sql/pglite": ">=0.2.0", "@libsql/client": ">=0.10.0", "@libsql/client-wasm": ">=0.10.0", "@neondatabase/serverless": ">=0.10.0", "@op-engineering/op-sqlite": ">=2", "@opentelemetry/api": "^1.4.1", "@planetscale/database": ">=1.13", "@prisma/client": "*", "@sqlitecloud/drivers": ">=1.0.653", "@tidbcloud/serverless": "*", "@tursodatabase/database": ">=0.2.1", "@tursodatabase/database-common": ">=0.2.1", "@tursodatabase/database-wasm": ">=0.2.1", "@types/better-sqlite3": "*", "@types/mssql": "^9.1.4", "@types/pg": "*", "@types/sql.js": "*", "@upstash/redis": ">=1.34.7", "@vercel/postgres": ">=0.8.0", "@xata.io/client": "*", "better-sqlite3": ">=9.3.0", "bun-types": "*", "expo-sqlite": ">=14.0.0", "gel": ">=2", "mssql": "^11.0.1", "mysql2": ">=2", "pg": ">=8", "postgres": ">=3", "sql.js": ">=1", "sqlite3": ">=5" }, "optionalPeers": ["@aws-sdk/client-rds-data", "@cloudflare/workers-types", "@effect/sql", "@effect/sql-pg", "@electric-sql/pglite", "@libsql/client", "@libsql/client-wasm", "@neondatabase/serverless", "@op-engineering/op-sqlite", "@opentelemetry/api", "@planetscale/database", "@prisma/client", "@sqlitecloud/drivers", "@tidbcloud/serverless", "@tursodatabase/database", "@tursodatabase/database-common", "@tursodatabase/database-wasm", "@types/better-sqlite3", "@types/pg", "@types/sql.js", "@upstash/redis", "@vercel/postgres", "@xata.io/client", "better-sqlite3", "bun-types", "expo-sqlite", "gel", "mysql2", "pg", "postgres", "sql.js", "sqlite3"] }, "sha512-wyOAgr9Cy9oEN6z5S0JGhfipLKbRRJtQKgbDO9SXGR9swMBbGNIlXkeMqPRrqYQ8k70mh+7ZJ/eVmJ2F7zR3Vg=="],
+    "drizzle-orm": ["[email protected]6-ea816b6", "", { "peerDependencies": { "@aws-sdk/client-rds-data": ">=3", "@cloudflare/workers-types": ">=4", "@effect/sql": "^0.48.5", "@effect/sql-pg": "^0.49.7", "@electric-sql/pglite": ">=0.2.0", "@libsql/client": ">=0.10.0", "@libsql/client-wasm": ">=0.10.0", "@neondatabase/serverless": ">=0.10.0", "@op-engineering/op-sqlite": ">=2", "@opentelemetry/api": "^1.4.1", "@planetscale/database": ">=1.13", "@prisma/client": "*", "@sinclair/typebox": ">=0.34.8", "@sqlitecloud/drivers": ">=1.0.653", "@tidbcloud/serverless": "*", "@tursodatabase/database": ">=0.2.1", "@tursodatabase/database-common": ">=0.2.1", "@tursodatabase/database-wasm": ">=0.2.1", "@types/better-sqlite3": "*", "@types/mssql": "^9.1.4", "@types/pg": "*", "@types/sql.js": "*", "@upstash/redis": ">=1.34.7", "@vercel/postgres": ">=0.8.0", "@xata.io/client": "*", "arktype": ">=2.0.0", "better-sqlite3": ">=9.3.0", "bun-types": "*", "expo-sqlite": ">=14.0.0", "gel": ">=2", "mssql": "^11.0.1", "mysql2": ">=2", "pg": ">=8", "postgres": ">=3", "sql.js": ">=1", "sqlite3": ">=5", "typebox": ">=1.0.0", "valibot": ">=1.0.0-beta.7", "zod": "^3.25.0 || ^4.0.0" }, "optionalPeers": ["@aws-sdk/client-rds-data", "@cloudflare/workers-types", "@effect/sql", "@effect/sql-pg", "@electric-sql/pglite", "@libsql/client", "@libsql/client-wasm", "@neondatabase/serverless", "@op-engineering/op-sqlite", "@opentelemetry/api", "@planetscale/database", "@prisma/client", "@sinclair/typebox", "@sqlitecloud/drivers", "@tidbcloud/serverless", "@tursodatabase/database", "@tursodatabase/database-common", "@tursodatabase/database-wasm", "@types/better-sqlite3", "@types/pg", "@types/sql.js", "@upstash/redis", "@vercel/postgres", "@xata.io/client", "arktype", "better-sqlite3", "bun-types", "expo-sqlite", "gel", "mysql2", "pg", "postgres", "sql.js", "sqlite3", "typebox", "valibot", "zod"] }, "sha512-k9gT4f0O9Qvah5YK/zL+FZonQ8TPyVxcG/ojN4dzO0fHP8hs8tBno8lqmJo53g0JLWv3Q2nsTUoyBRKM2TljFw=="],
 
 
     "dset": ["[email protected]", "", {}, "sha512-2QF/g9/zTaPDc3BjNcVTGoBbXBgYfMTTceLaYcFJ/W9kggFUkhxD/hMEeuLKbugyef9SqAx8cpgwlIP/jinUTA=="],
     "dset": ["[email protected]", "", {}, "sha512-2QF/g9/zTaPDc3BjNcVTGoBbXBgYfMTTceLaYcFJ/W9kggFUkhxD/hMEeuLKbugyef9SqAx8cpgwlIP/jinUTA=="],
 
 
@@ -5270,6 +5270,8 @@
 
 
     "cross-spawn/which": ["[email protected]", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="],
     "cross-spawn/which": ["[email protected]", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="],
 
 
+    "db0/drizzle-orm": ["[email protected]", "", { "peerDependencies": { "@aws-sdk/client-rds-data": ">=3", "@cloudflare/workers-types": ">=4", "@effect/sql": "^0.48.5", "@effect/sql-pg": "^0.49.7", "@electric-sql/pglite": ">=0.2.0", "@libsql/client": ">=0.10.0", "@libsql/client-wasm": ">=0.10.0", "@neondatabase/serverless": ">=0.10.0", "@op-engineering/op-sqlite": ">=2", "@opentelemetry/api": "^1.4.1", "@planetscale/database": ">=1.13", "@prisma/client": "*", "@sqlitecloud/drivers": ">=1.0.653", "@tidbcloud/serverless": "*", "@tursodatabase/database": ">=0.2.1", "@tursodatabase/database-common": ">=0.2.1", "@tursodatabase/database-wasm": ">=0.2.1", "@types/better-sqlite3": "*", "@types/mssql": "^9.1.4", "@types/pg": "*", "@types/sql.js": "*", "@upstash/redis": ">=1.34.7", "@vercel/postgres": ">=0.8.0", "@xata.io/client": "*", "better-sqlite3": ">=9.3.0", "bun-types": "*", "expo-sqlite": ">=14.0.0", "gel": ">=2", "mssql": "^11.0.1", "mysql2": ">=2", "pg": ">=8", "postgres": ">=3", "sql.js": ">=1", "sqlite3": ">=5" }, "optionalPeers": ["@aws-sdk/client-rds-data", "@cloudflare/workers-types", "@effect/sql", "@effect/sql-pg", "@electric-sql/pglite", "@libsql/client", "@libsql/client-wasm", "@neondatabase/serverless", "@op-engineering/op-sqlite", "@opentelemetry/api", "@planetscale/database", "@prisma/client", "@sqlitecloud/drivers", "@tidbcloud/serverless", "@tursodatabase/database", "@tursodatabase/database-common", "@tursodatabase/database-wasm", "@types/better-sqlite3", "@types/pg", "@types/sql.js", "@upstash/redis", "@vercel/postgres", "@xata.io/client", "better-sqlite3", "bun-types", "expo-sqlite", "gel", "mysql2", "pg", "postgres", "sql.js", "sqlite3"] }, "sha512-wyOAgr9Cy9oEN6z5S0JGhfipLKbRRJtQKgbDO9SXGR9swMBbGNIlXkeMqPRrqYQ8k70mh+7ZJ/eVmJ2F7zR3Vg=="],
+
     "defaults/clone": ["[email protected]", "", {}, "sha512-JQHZ2QMW6l3aH/j6xCqQThY/9OH4D/9ls34cgkUBiEeocRTU04tHfKPBsUK1PqZCUQM7GiA0IIXJSuXHI64Kbg=="],
     "defaults/clone": ["[email protected]", "", {}, "sha512-JQHZ2QMW6l3aH/j6xCqQThY/9OH4D/9ls34cgkUBiEeocRTU04tHfKPBsUK1PqZCUQM7GiA0IIXJSuXHI64Kbg=="],
 
 
     "dir-compare/minimatch": ["[email protected]", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w=="],
     "dir-compare/minimatch": ["[email protected]", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w=="],

+ 4 - 4
nix/hashes.json

@@ -1,8 +1,8 @@
 {
 {
   "nodeModules": {
   "nodeModules": {
-    "x86_64-linux": "sha256-pBTIT8Pgdm3272YhBjiAZsmj0SSpHTklh6lGc8YcMoE=",
-    "aarch64-linux": "sha256-prt039++d5UZgtldAN6+RVOR557ifIeusiy5XpzN8QU=",
-    "aarch64-darwin": "sha256-Y3f+cXcIGLqz6oyc5fG22t6CLD4wGkvwqO6RNXjFriQ=",
-    "x86_64-darwin": "sha256-BjbBBhQUgGhrlP56skABcrObvutNUZSWnrnPCg1OTKE="
+    "x86_64-linux": "sha256-4kjoJ06VNvHltPHfzQRBG0bC6R39jao10ffGzrNZ230=",
+    "aarch64-linux": "sha256-6Uio+S2rcyBWbBEeOZb9N1CCKgkbKi68lOIKi3Ws/pQ=",
+    "aarch64-darwin": "sha256-8ngN5KVN4vhdsk0QJ11BGgSVBrcaEbwSj23c77HBpgs=",
+    "x86_64-darwin": "sha256-v/ueYGb9a0Nymzy+mkO4uQr78DAuJnES1qOT0onFgnQ="
   }
   }
 }
 }

+ 2 - 2
package.json

@@ -41,8 +41,8 @@
       "@tailwindcss/vite": "4.1.11",
       "@tailwindcss/vite": "4.1.11",
       "diff": "8.0.2",
       "diff": "8.0.2",
       "dompurify": "3.3.1",
       "dompurify": "3.3.1",
-      "drizzle-kit": "1.0.0-beta.12-a5629fb",
-      "drizzle-orm": "1.0.0-beta.12-a5629fb",
+      "drizzle-kit": "1.0.0-beta.16-ea816b6",
+      "drizzle-orm": "1.0.0-beta.16-ea816b6",
       "ai": "5.0.124",
       "ai": "5.0.124",
       "hono": "4.10.7",
       "hono": "4.10.7",
       "hono-openapi": "1.1.2",
       "hono-openapi": "1.1.2",

+ 16 - 4
packages/app/e2e/AGENTS.md

@@ -71,6 +71,12 @@ test("test description", async ({ page, sdk, gotoSession }) => {
 - `closeDialog(page, dialog)` - Close any dialog
 - `closeDialog(page, dialog)` - Close any dialog
 - `openSidebar(page)` / `closeSidebar(page)` - Toggle sidebar
 - `openSidebar(page)` / `closeSidebar(page)` - Toggle sidebar
 - `withSession(sdk, title, callback)` - Create temp session
 - `withSession(sdk, title, callback)` - Create temp session
+- `withProject(...)` - Create temp project/workspace
+- `sessionIDFromUrl(url)` - Read session ID from URL
+- `slugFromUrl(url)` - Read workspace slug from URL
+- `waitSlug(page, skip?)` - Wait for resolved workspace slug
+- `trackSession(sessionID, directory?)` - Register session for fixture cleanup
+- `trackDirectory(directory)` - Register directory for fixture cleanup
 - `clickListItem(container, filter)` - Click list item by key/text
 - `clickListItem(container, filter)` - Click list item by key/text
 
 
 **Selectors** (`selectors.ts`):
 **Selectors** (`selectors.ts`):
@@ -109,7 +115,7 @@ import { test, expect } from "@playwright/test"
 
 
 ### Error Handling
 ### Error Handling
 
 
-Tests should clean up after themselves:
+Tests should clean up after themselves. Prefer fixture-managed cleanup:
 
 
 ```typescript
 ```typescript
 test("test with cleanup", async ({ page, sdk, gotoSession }) => {
 test("test with cleanup", async ({ page, sdk, gotoSession }) => {
@@ -120,6 +126,11 @@ test("test with cleanup", async ({ page, sdk, gotoSession }) => {
 })
 })
 ```
 ```
 
 
+- Prefer `withSession(...)` for temp sessions
+- In `withProject(...)` tests that create sessions or extra workspaces, call `trackSession(sessionID, directory?)` and `trackDirectory(directory)`
+- This lets fixture teardown abort, wait for idle, and clean up safely under CI concurrency
+- Avoid calling `sdk.session.delete(...)` directly
+
 ### Timeouts
 ### Timeouts
 
 
 Default: 60s per test, 10s per assertion. Override when needed:
 Default: 60s per test, 10s per assertion. Override when needed:
@@ -161,9 +172,10 @@ await page.keyboard.press(`${modKey}+Comma`) // Open settings
 1. Choose appropriate folder or create new one
 1. Choose appropriate folder or create new one
 2. Import from `../fixtures`
 2. Import from `../fixtures`
 3. Use helper functions from `../actions` and `../selectors`
 3. Use helper functions from `../actions` and `../selectors`
-4. Clean up any created resources
-5. Use specific selectors (avoid CSS classes)
-6. Test one feature per test file
+4. When validating routing, use shared helpers from `../actions`. Workspace URL slugs can be canonicalized on Windows, so assert against canonical or resolved workspace slugs.
+5. Clean up any created resources
+6. Use specific selectors (avoid CSS classes)
+7. Test one feature per test file
 
 
 ## Local Development
 ## Local Development
 
 

+ 185 - 51
packages/app/e2e/actions.ts

@@ -3,12 +3,13 @@ import fs from "node:fs/promises"
 import os from "node:os"
 import os from "node:os"
 import path from "node:path"
 import path from "node:path"
 import { execSync } from "node:child_process"
 import { execSync } from "node:child_process"
-import { modKey, serverUrl } from "./utils"
+import { createSdk, modKey, resolveDirectory, serverUrl } from "./utils"
 import {
 import {
-  sessionItemSelector,
   dropdownMenuTriggerSelector,
   dropdownMenuTriggerSelector,
   dropdownMenuContentSelector,
   dropdownMenuContentSelector,
+  sessionTimelineHeaderSelector,
   projectMenuTriggerSelector,
   projectMenuTriggerSelector,
+  projectCloseMenuSelector,
   projectWorkspacesToggleSelector,
   projectWorkspacesToggleSelector,
   titlebarRightSelector,
   titlebarRightSelector,
   popoverBodySelector,
   popoverBodySelector,
@@ -18,7 +19,6 @@ import {
   workspaceItemSelector,
   workspaceItemSelector,
   workspaceMenuTriggerSelector,
   workspaceMenuTriggerSelector,
 } from "./selectors"
 } from "./selectors"
-import type { createSdk } from "./utils"
 
 
 export async function defocus(page: Page) {
 export async function defocus(page: Page) {
   await page
   await page
@@ -61,9 +61,9 @@ export async function closeDialog(page: Page, dialog: Locator) {
 }
 }
 
 
 export async function isSidebarClosed(page: Page) {
 export async function isSidebarClosed(page: Page) {
-  const main = page.locator("main")
-  const classes = (await main.getAttribute("class")) ?? ""
-  return classes.includes("xl:border-l")
+  const button = page.getByRole("button", { name: /toggle sidebar/i }).first()
+  await expect(button).toBeVisible()
+  return (await button.getAttribute("aria-expanded")) !== "true"
 }
 }
 
 
 export async function toggleSidebar(page: Page) {
 export async function toggleSidebar(page: Page) {
@@ -75,48 +75,34 @@ export async function openSidebar(page: Page) {
   if (!(await isSidebarClosed(page))) return
   if (!(await isSidebarClosed(page))) return
 
 
   const button = page.getByRole("button", { name: /toggle sidebar/i }).first()
   const button = page.getByRole("button", { name: /toggle sidebar/i }).first()
-  const visible = await button
-    .isVisible()
-    .then((x) => x)
-    .catch(() => false)
-
-  if (visible) await button.click()
-  if (!visible) await toggleSidebar(page)
+  await button.click()
 
 
-  const main = page.locator("main")
-  const opened = await expect(main)
-    .not.toHaveClass(/xl:border-l/, { timeout: 1500 })
+  const opened = await expect(button)
+    .toHaveAttribute("aria-expanded", "true", { timeout: 1500 })
     .then(() => true)
     .then(() => true)
     .catch(() => false)
     .catch(() => false)
 
 
   if (opened) return
   if (opened) return
 
 
   await toggleSidebar(page)
   await toggleSidebar(page)
-  await expect(main).not.toHaveClass(/xl:border-l/)
+  await expect(button).toHaveAttribute("aria-expanded", "true")
 }
 }
 
 
 export async function closeSidebar(page: Page) {
 export async function closeSidebar(page: Page) {
   if (await isSidebarClosed(page)) return
   if (await isSidebarClosed(page)) return
 
 
   const button = page.getByRole("button", { name: /toggle sidebar/i }).first()
   const button = page.getByRole("button", { name: /toggle sidebar/i }).first()
-  const visible = await button
-    .isVisible()
-    .then((x) => x)
-    .catch(() => false)
-
-  if (visible) await button.click()
-  if (!visible) await toggleSidebar(page)
+  await button.click()
 
 
-  const main = page.locator("main")
-  const closed = await expect(main)
-    .toHaveClass(/xl:border-l/, { timeout: 1500 })
+  const closed = await expect(button)
+    .toHaveAttribute("aria-expanded", "false", { timeout: 1500 })
     .then(() => true)
     .then(() => true)
     .catch(() => false)
     .catch(() => false)
 
 
   if (closed) return
   if (closed) return
 
 
   await toggleSidebar(page)
   await toggleSidebar(page)
-  await expect(main).toHaveClass(/xl:border-l/)
+  await expect(button).toHaveAttribute("aria-expanded", "false")
 }
 }
 
 
 export async function openSettings(page: Page) {
 export async function openSettings(page: Page) {
@@ -204,7 +190,7 @@ export async function createTestProject() {
     stdio: "ignore",
     stdio: "ignore",
   })
   })
 
 
-  return root
+  return resolveDirectory(root)
 }
 }
 
 
 export async function cleanupTestProject(directory: string) {
 export async function cleanupTestProject(directory: string) {
@@ -214,13 +200,40 @@ export async function cleanupTestProject(directory: string) {
   await fs.rm(directory, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }).catch(() => undefined)
   await fs.rm(directory, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }).catch(() => undefined)
 }
 }
 
 
+export function slugFromUrl(url: string) {
+  return /\/([^/]+)\/session(?:[/?#]|$)/.exec(url)?.[1] ?? ""
+}
+
+export async function waitSlug(page: Page, skip: string[] = []) {
+  let prev = ""
+  let next = ""
+  await expect
+    .poll(
+      () => {
+        const slug = slugFromUrl(page.url())
+        if (!slug) return ""
+        if (skip.includes(slug)) return ""
+        if (slug !== prev) {
+          prev = slug
+          next = ""
+          return ""
+        }
+        next = slug
+        return slug
+      },
+      { timeout: 45_000 },
+    )
+    .not.toBe("")
+  return next
+}
+
 export function sessionIDFromUrl(url: string) {
 export function sessionIDFromUrl(url: string) {
   const match = /\/session\/([^/?#]+)/.exec(url)
   const match = /\/session\/([^/?#]+)/.exec(url)
   return match?.[1]
   return match?.[1]
 }
 }
 
 
 export async function hoverSessionItem(page: Page, sessionID: string) {
 export async function hoverSessionItem(page: Page, sessionID: string) {
-  const sessionEl = page.locator(sessionItemSelector(sessionID)).first()
+  const sessionEl = page.locator(`[data-session-id="${sessionID}"]`).last()
   await expect(sessionEl).toBeVisible()
   await expect(sessionEl).toBeVisible()
   await sessionEl.hover()
   await sessionEl.hover()
   return sessionEl
   return sessionEl
@@ -231,7 +244,9 @@ export async function openSessionMoreMenu(page: Page, sessionID: string) {
 
 
   const scroller = page.locator(".scroll-view__viewport").first()
   const scroller = page.locator(".scroll-view__viewport").first()
   await expect(scroller).toBeVisible()
   await expect(scroller).toBeVisible()
-  await expect(scroller.getByRole("heading", { level: 1 }).first()).toBeVisible({ timeout: 30_000 })
+  const header = page.locator(sessionTimelineHeaderSelector).first()
+  await expect(header).toBeVisible({ timeout: 30_000 })
+  await expect(header.getByRole("heading", { level: 1 }).first()).toBeVisible({ timeout: 30_000 })
 
 
   const menu = page
   const menu = page
     .locator(dropdownMenuContentSelector)
     .locator(dropdownMenuContentSelector)
@@ -247,7 +262,7 @@ export async function openSessionMoreMenu(page: Page, sessionID: string) {
 
 
   if (opened) return menu
   if (opened) return menu
 
 
-  const menuTrigger = scroller.getByRole("button", { name: /more options/i }).first()
+  const menuTrigger = header.getByRole("button", { name: /more options/i }).first()
   await expect(menuTrigger).toBeVisible()
   await expect(menuTrigger).toBeVisible()
   await menuTrigger.click()
   await menuTrigger.click()
 
 
@@ -321,6 +336,57 @@ export async function clickListItem(
   return item
   return item
 }
 }
 
 
+async function status(sdk: ReturnType<typeof createSdk>, sessionID: string) {
+  const data = await sdk.session
+    .status()
+    .then((x) => x.data ?? {})
+    .catch(() => undefined)
+  return data?.[sessionID]
+}
+
+async function stable(sdk: ReturnType<typeof createSdk>, sessionID: string, timeout = 10_000) {
+  let prev = ""
+  await expect
+    .poll(
+      async () => {
+        const info = await sdk.session
+          .get({ sessionID })
+          .then((x) => x.data)
+          .catch(() => undefined)
+        if (!info) return true
+        const next = `${info.title}:${info.time.updated ?? info.time.created}`
+        if (next !== prev) {
+          prev = next
+          return false
+        }
+        return true
+      },
+      { timeout },
+    )
+    .toBe(true)
+}
+
+export async function waitSessionIdle(sdk: ReturnType<typeof createSdk>, sessionID: string, timeout = 30_000) {
+  await expect.poll(() => status(sdk, sessionID).then((x) => !x || x.type === "idle"), { timeout }).toBe(true)
+}
+
+export async function cleanupSession(input: {
+  sessionID: string
+  directory?: string
+  sdk?: ReturnType<typeof createSdk>
+}) {
+  const sdk = input.sdk ?? (input.directory ? createSdk(input.directory) : undefined)
+  if (!sdk) throw new Error("cleanupSession requires sdk or directory")
+  await waitSessionIdle(sdk, input.sessionID, 5_000).catch(() => undefined)
+  const current = await status(sdk, input.sessionID).catch(() => undefined)
+  if (current && current.type !== "idle") {
+    await sdk.session.abort({ sessionID: input.sessionID }).catch(() => undefined)
+    await waitSessionIdle(sdk, input.sessionID).catch(() => undefined)
+  }
+  await stable(sdk, input.sessionID).catch(() => undefined)
+  await sdk.session.delete({ sessionID: input.sessionID }).catch(() => undefined)
+}
+
 export async function withSession<T>(
 export async function withSession<T>(
   sdk: ReturnType<typeof createSdk>,
   sdk: ReturnType<typeof createSdk>,
   title: string,
   title: string,
@@ -332,7 +398,7 @@ export async function withSession<T>(
   try {
   try {
     return await callback(session)
     return await callback(session)
   } finally {
   } finally {
-    await sdk.session.delete({ sessionID: session.id }).catch(() => undefined)
+    await cleanupSession({ sdk, sessionID: session.id })
   }
   }
 }
 }
 
 
@@ -445,6 +511,57 @@ export async function seedSessionPermission(
   return { id: result.id }
   return { id: result.id }
 }
 }
 
 
+export async function seedSessionTask(
+  sdk: ReturnType<typeof createSdk>,
+  input: {
+    sessionID: string
+    description: string
+    prompt: string
+    subagentType?: string
+  },
+) {
+  const text = [
+    "Your only valid response is one task tool call.",
+    `Use this JSON input: ${JSON.stringify({
+      description: input.description,
+      prompt: input.prompt,
+      subagent_type: input.subagentType ?? "general",
+    })}`,
+    "Do not output plain text.",
+    "Wait for the task to start and return the child session id.",
+  ].join("\n")
+
+  const result = await seed({
+    sdk,
+    sessionID: input.sessionID,
+    prompt: text,
+    timeout: 90_000,
+    probe: async () => {
+      const messages = await sdk.session.messages({ sessionID: input.sessionID, limit: 50 }).then((x) => x.data ?? [])
+      const part = messages
+        .flatMap((message) => message.parts)
+        .find((part) => {
+          if (part.type !== "tool" || part.tool !== "task") return false
+          if (part.state.input?.description !== input.description) return false
+          return typeof part.state.metadata?.sessionId === "string" && part.state.metadata.sessionId.length > 0
+        })
+
+      if (!part) return
+      const id = part.state.metadata?.sessionId
+      if (typeof id !== "string" || !id) return
+      const child = await sdk.session
+        .get({ sessionID: id })
+        .then((x) => x.data)
+        .catch(() => undefined)
+      if (!child?.id) return
+      return { sessionID: id }
+    },
+  })
+
+  if (!result) throw new Error("Timed out seeding task tool")
+  return result
+}
+
 export async function seedSessionTodos(
 export async function seedSessionTodos(
   sdk: ReturnType<typeof createSdk>,
   sdk: ReturnType<typeof createSdk>,
   input: {
   input: {
@@ -519,32 +636,42 @@ export async function openProjectMenu(page: Page, projectSlug: string) {
   const trigger = page.locator(projectMenuTriggerSelector(projectSlug)).first()
   const trigger = page.locator(projectMenuTriggerSelector(projectSlug)).first()
   await expect(trigger).toHaveCount(1)
   await expect(trigger).toHaveCount(1)
 
 
+  const menu = page
+    .locator(dropdownMenuContentSelector)
+    .filter({ has: page.locator(projectCloseMenuSelector(projectSlug)) })
+    .first()
+  const close = menu.locator(projectCloseMenuSelector(projectSlug)).first()
+
+  const clicked = await trigger
+    .click({ timeout: 1500 })
+    .then(() => true)
+    .catch(() => false)
+
+  if (clicked) {
+    const opened = await menu
+      .waitFor({ state: "visible", timeout: 1500 })
+      .then(() => true)
+      .catch(() => false)
+    if (opened) {
+      await expect(close).toBeVisible()
+      return menu
+    }
+  }
+
   await trigger.focus()
   await trigger.focus()
   await page.keyboard.press("Enter")
   await page.keyboard.press("Enter")
 
 
-  const menu = page.locator(dropdownMenuContentSelector).first()
   const opened = await menu
   const opened = await menu
     .waitFor({ state: "visible", timeout: 1500 })
     .waitFor({ state: "visible", timeout: 1500 })
     .then(() => true)
     .then(() => true)
     .catch(() => false)
     .catch(() => false)
 
 
   if (opened) {
   if (opened) {
-    const viewport = page.viewportSize()
-    const x = viewport ? Math.max(viewport.width - 5, 0) : 1200
-    const y = viewport ? Math.max(viewport.height - 5, 0) : 800
-    await page.mouse.move(x, y)
+    await expect(close).toBeVisible()
     return menu
     return menu
   }
   }
 
 
-  await trigger.click({ force: true })
-
-  await expect(menu).toBeVisible()
-
-  const viewport = page.viewportSize()
-  const x = viewport ? Math.max(viewport.width - 5, 0) : 1200
-  const y = viewport ? Math.max(viewport.height - 5, 0) : 800
-  await page.mouse.move(x, y)
-  return menu
+  throw new Error(`Failed to open project menu: ${projectSlug}`)
 }
 }
 
 
 export async function setWorkspacesEnabled(page: Page, projectSlug: string, enabled: boolean) {
 export async function setWorkspacesEnabled(page: Page, projectSlug: string, enabled: boolean) {
@@ -557,11 +684,18 @@ export async function setWorkspacesEnabled(page: Page, projectSlug: string, enab
 
 
   if (current === enabled) return
   if (current === enabled) return
 
 
-  await openProjectMenu(page, projectSlug)
+  const flip = async (timeout?: number) => {
+    const menu = await openProjectMenu(page, projectSlug)
+    const toggle = menu.locator(projectWorkspacesToggleSelector(projectSlug)).first()
+    await expect(toggle).toBeVisible()
+    return toggle.click({ force: true, timeout })
+  }
+
+  const flipped = await flip(1500)
+    .then(() => true)
+    .catch(() => false)
 
 
-  const toggle = page.locator(projectWorkspacesToggleSelector(projectSlug)).first()
-  await expect(toggle).toBeVisible()
-  await toggle.click({ force: true })
+  if (!flipped) await flip()
 
 
   const expected = enabled ? "New workspace" : "New session"
   const expected = enabled ? "New workspace" : "New session"
   await expect(page.getByRole("button", { name: expected }).first()).toBeVisible()
   await expect(page.getByRole("button", { name: expected }).first()).toBeVisible()

+ 3 - 3
packages/app/e2e/app/home.spec.ts

@@ -1,17 +1,17 @@
 import { test, expect } from "../fixtures"
 import { test, expect } from "../fixtures"
-import { serverName } from "../utils"
+import { serverNamePattern } from "../utils"
 
 
 test("home renders and shows core entrypoints", async ({ page }) => {
 test("home renders and shows core entrypoints", async ({ page }) => {
   await page.goto("/")
   await page.goto("/")
 
 
   await expect(page.getByRole("button", { name: "Open project" }).first()).toBeVisible()
   await expect(page.getByRole("button", { name: "Open project" }).first()).toBeVisible()
-  await expect(page.getByRole("button", { name: serverName })).toBeVisible()
+  await expect(page.getByRole("button", { name: serverNamePattern })).toBeVisible()
 })
 })
 
 
 test("server picker dialog opens from home", async ({ page }) => {
 test("server picker dialog opens from home", async ({ page }) => {
   await page.goto("/")
   await page.goto("/")
 
 
-  const trigger = page.getByRole("button", { name: serverName })
+  const trigger = page.getByRole("button", { name: serverNamePattern })
   await expect(trigger).toBeVisible()
   await expect(trigger).toBeVisible()
   await trigger.click()
   await trigger.click()
 
 

+ 11 - 8
packages/app/e2e/app/server-default.spec.ts

@@ -1,6 +1,6 @@
 import { test, expect } from "../fixtures"
 import { test, expect } from "../fixtures"
-import { serverName, serverUrl } from "../utils"
-import { clickListItem, closeDialog, clickMenuItem } from "../actions"
+import { serverNamePattern, serverUrls } from "../utils"
+import { closeDialog, clickMenuItem } from "../actions"
 
 
 const DEFAULT_SERVER_URL_KEY = "opencode.settings.dat:defaultServerUrl"
 const DEFAULT_SERVER_URL_KEY = "opencode.settings.dat:defaultServerUrl"
 
 
@@ -31,10 +31,9 @@ test("can set a default server on web", async ({ page, gotoSession }) => {
   const dialog = page.getByRole("dialog")
   const dialog = page.getByRole("dialog")
   await expect(dialog).toBeVisible()
   await expect(dialog).toBeVisible()
 
 
-  const row = dialog.locator('[data-slot="list-item"]').filter({ hasText: serverName }).first()
-  await expect(row).toBeVisible()
+  await expect(dialog.getByText(serverNamePattern).first()).toBeVisible()
 
 
-  const menuTrigger = row.locator('[data-slot="dropdown-menu-trigger"]').first()
+  const menuTrigger = dialog.locator('[data-slot="dropdown-menu-trigger"]').first()
   await expect(menuTrigger).toBeVisible()
   await expect(menuTrigger).toBeVisible()
   await menuTrigger.click({ force: true })
   await menuTrigger.click({ force: true })
 
 
@@ -42,14 +41,18 @@ test("can set a default server on web", async ({ page, gotoSession }) => {
   await expect(menu).toBeVisible()
   await expect(menu).toBeVisible()
   await clickMenuItem(menu, /set as default/i)
   await clickMenuItem(menu, /set as default/i)
 
 
-  await expect.poll(() => page.evaluate((key) => localStorage.getItem(key), DEFAULT_SERVER_URL_KEY)).toBe(serverUrl)
-  await expect(row.getByText("Default", { exact: true })).toBeVisible()
+  await expect
+    .poll(async () =>
+      serverUrls.includes((await page.evaluate((key) => localStorage.getItem(key), DEFAULT_SERVER_URL_KEY)) ?? ""),
+    )
+    .toBe(true)
+  await expect(dialog.getByText("Default", { exact: true })).toBeVisible()
 
 
   await closeDialog(page, dialog)
   await closeDialog(page, dialog)
 
 
   await ensurePopoverOpen()
   await ensurePopoverOpen()
 
 
-  const serverRow = popover.locator("button").filter({ hasText: serverName }).first()
+  const serverRow = popover.locator("button").filter({ hasText: serverNamePattern }).first()
   await expect(serverRow).toBeVisible()
   await expect(serverRow).toBeVisible()
   await expect(serverRow.getByText("Default", { exact: true })).toBeVisible()
   await expect(serverRow.getByText("Default", { exact: true })).toBeVisible()
 })
 })

+ 0 - 4
packages/app/e2e/app/titlebar-history.spec.ts

@@ -16,7 +16,6 @@ test("titlebar back/forward navigates between sessions", async ({ page, slug, sd
 
 
       const link = page.locator(`[data-session-id="${two.id}"] a`).first()
       const link = page.locator(`[data-session-id="${two.id}"] a`).first()
       await expect(link).toBeVisible()
       await expect(link).toBeVisible()
-      await link.scrollIntoViewIfNeeded()
       await link.click()
       await link.click()
 
 
       await expect(page).toHaveURL(new RegExp(`/${slug}/session/${two.id}(?:\\?|#|$)`))
       await expect(page).toHaveURL(new RegExp(`/${slug}/session/${two.id}(?:\\?|#|$)`))
@@ -56,7 +55,6 @@ test("titlebar forward is cleared after branching history from sidebar", async (
 
 
         const second = page.locator(`[data-session-id="${b.id}"] a`).first()
         const second = page.locator(`[data-session-id="${b.id}"] a`).first()
         await expect(second).toBeVisible()
         await expect(second).toBeVisible()
-        await second.scrollIntoViewIfNeeded()
         await second.click()
         await second.click()
 
 
         await expect(page).toHaveURL(new RegExp(`/${slug}/session/${b.id}(?:\\?|#|$)`))
         await expect(page).toHaveURL(new RegExp(`/${slug}/session/${b.id}(?:\\?|#|$)`))
@@ -76,7 +74,6 @@ test("titlebar forward is cleared after branching history from sidebar", async (
 
 
         const third = page.locator(`[data-session-id="${c.id}"] a`).first()
         const third = page.locator(`[data-session-id="${c.id}"] a`).first()
         await expect(third).toBeVisible()
         await expect(third).toBeVisible()
-        await third.scrollIntoViewIfNeeded()
         await third.click()
         await third.click()
 
 
         await expect(page).toHaveURL(new RegExp(`/${slug}/session/${c.id}(?:\\?|#|$)`))
         await expect(page).toHaveURL(new RegExp(`/${slug}/session/${c.id}(?:\\?|#|$)`))
@@ -102,7 +99,6 @@ test("keyboard shortcuts navigate titlebar history", async ({ page, slug, sdk, g
 
 
       const link = page.locator(`[data-session-id="${two.id}"] a`).first()
       const link = page.locator(`[data-session-id="${two.id}"] a`).first()
       await expect(link).toBeVisible()
       await expect(link).toBeVisible()
-      await link.scrollIntoViewIfNeeded()
       await link.click()
       await link.click()
 
 
       await expect(page).toHaveURL(new RegExp(`/${slug}/session/${two.id}(?:\\?|#|$)`))
       await expect(page).toHaveURL(new RegExp(`/${slug}/session/${two.id}(?:\\?|#|$)`))

+ 5 - 3
packages/app/e2e/commands/panels.spec.ts

@@ -10,6 +10,8 @@ const expanded = async (el: { getAttribute: (name: string) => Promise<string | n
 test("review panel can be toggled via keybind", async ({ page, gotoSession }) => {
 test("review panel can be toggled via keybind", async ({ page, gotoSession }) => {
   await gotoSession()
   await gotoSession()
 
 
+  const reviewPanel = page.locator("#review-panel")
+
   const treeToggle = page.getByRole("button", { name: "Toggle file tree" }).first()
   const treeToggle = page.getByRole("button", { name: "Toggle file tree" }).first()
   await expect(treeToggle).toBeVisible()
   await expect(treeToggle).toBeVisible()
   if (await expanded(treeToggle)) await treeToggle.click()
   if (await expanded(treeToggle)) await treeToggle.click()
@@ -19,13 +21,13 @@ test("review panel can be toggled via keybind", async ({ page, gotoSession }) =>
   await expect(reviewToggle).toBeVisible()
   await expect(reviewToggle).toBeVisible()
   if (await expanded(reviewToggle)) await reviewToggle.click()
   if (await expanded(reviewToggle)) await reviewToggle.click()
   await expect(reviewToggle).toHaveAttribute("aria-expanded", "false")
   await expect(reviewToggle).toHaveAttribute("aria-expanded", "false")
-  await expect(page.locator("#review-panel")).toHaveCount(0)
+  await expect(reviewPanel).toHaveAttribute("aria-hidden", "true")
 
 
   await page.keyboard.press(`${modKey}+Shift+R`)
   await page.keyboard.press(`${modKey}+Shift+R`)
   await expect(reviewToggle).toHaveAttribute("aria-expanded", "true")
   await expect(reviewToggle).toHaveAttribute("aria-expanded", "true")
-  await expect(page.locator("#review-panel")).toBeVisible()
+  await expect(reviewPanel).toHaveAttribute("aria-hidden", "false")
 
 
   await page.keyboard.press(`${modKey}+Shift+R`)
   await page.keyboard.press(`${modKey}+Shift+R`)
   await expect(reviewToggle).toHaveAttribute("aria-expanded", "false")
   await expect(reviewToggle).toHaveAttribute("aria-expanded", "false")
-  await expect(page.locator("#review-panel")).toHaveCount(0)
+  await expect(reviewPanel).toHaveAttribute("aria-hidden", "true")
 })
 })

+ 7 - 0
packages/app/e2e/files/file-tree.spec.ts

@@ -43,6 +43,13 @@ test("file tree can expand folders and open a file", async ({ page, gotoSession
   await tab.click()
   await tab.click()
   await expect(tab).toHaveAttribute("aria-selected", "true")
   await expect(tab).toHaveAttribute("aria-selected", "true")
 
 
+  await toggle.click()
+  await expect(toggle).toHaveAttribute("aria-expanded", "false")
+
+  await toggle.click()
+  await expect(toggle).toHaveAttribute("aria-expanded", "true")
+  await expect(allTab).toHaveAttribute("aria-selected", "true")
+
   const viewer = page.locator('[data-component="file"][data-mode="text"]').first()
   const viewer = page.locator('[data-component="file"][data-mode="text"]').first()
   await expect(viewer).toBeVisible()
   await expect(viewer).toBeVisible()
   await expect(viewer).toContainText("export default function FileTree")
   await expect(viewer).toContainText("export default function FileTree")

+ 25 - 7
packages/app/e2e/fixtures.ts

@@ -1,5 +1,5 @@
 import { test as base, expect, type Page } from "@playwright/test"
 import { test as base, expect, type Page } from "@playwright/test"
-import { cleanupTestProject, createTestProject, seedProjects } from "./actions"
+import { cleanupSession, cleanupTestProject, createTestProject, seedProjects, sessionIDFromUrl } from "./actions"
 import { promptSelector } from "./selectors"
 import { promptSelector } from "./selectors"
 import { createSdk, dirSlug, getWorktree, sessionPath } from "./utils"
 import { createSdk, dirSlug, getWorktree, sessionPath } from "./utils"
 
 
@@ -13,6 +13,8 @@ type TestFixtures = {
       directory: string
       directory: string
       slug: string
       slug: string
       gotoSession: (sessionID?: string) => Promise<void>
       gotoSession: (sessionID?: string) => Promise<void>
+      trackSession: (sessionID: string, directory?: string) => void
+      trackDirectory: (directory: string) => void
     }) => Promise<T>,
     }) => Promise<T>,
     options?: { extra?: string[] },
     options?: { extra?: string[] },
   ) => Promise<T>
   ) => Promise<T>
@@ -51,20 +53,36 @@ export const test = base.extend<TestFixtures, WorkerFixtures>({
   },
   },
   withProject: async ({ page }, use) => {
   withProject: async ({ page }, use) => {
     await use(async (callback, options) => {
     await use(async (callback, options) => {
-      const directory = await createTestProject()
-      const slug = dirSlug(directory)
-      await seedStorage(page, { directory, extra: options?.extra })
+      const root = await createTestProject()
+      const slug = dirSlug(root)
+      const sessions = new Map<string, string>()
+      const dirs = new Set<string>()
+      await seedStorage(page, { directory: root, extra: options?.extra })
 
 
       const gotoSession = async (sessionID?: string) => {
       const gotoSession = async (sessionID?: string) => {
-        await page.goto(sessionPath(directory, sessionID))
+        await page.goto(sessionPath(root, sessionID))
         await expect(page.locator(promptSelector)).toBeVisible()
         await expect(page.locator(promptSelector)).toBeVisible()
+        const current = sessionIDFromUrl(page.url())
+        if (current) trackSession(current)
+      }
+
+      const trackSession = (sessionID: string, directory?: string) => {
+        sessions.set(sessionID, directory ?? root)
+      }
+
+      const trackDirectory = (directory: string) => {
+        if (directory !== root) dirs.add(directory)
       }
       }
 
 
       try {
       try {
         await gotoSession()
         await gotoSession()
-        return await callback({ directory, slug, gotoSession })
+        return await callback({ directory: root, slug, gotoSession, trackSession, trackDirectory })
       } finally {
       } finally {
-        await cleanupTestProject(directory)
+        await Promise.allSettled(
+          Array.from(sessions, ([sessionID, directory]) => cleanupSession({ sessionID, directory })),
+        )
+        await Promise.allSettled(Array.from(dirs, (directory) => cleanupTestProject(directory)))
+        await cleanupTestProject(root)
       }
       }
     })
     })
   },
   },

+ 4 - 14
packages/app/e2e/projects/project-edit.spec.ts

@@ -1,25 +1,15 @@
 import { test, expect } from "../fixtures"
 import { test, expect } from "../fixtures"
-import { openSidebar } from "../actions"
+import { clickMenuItem, openProjectMenu, openSidebar } from "../actions"
 
 
 test("dialog edit project updates name and startup script", async ({ page, withProject }) => {
 test("dialog edit project updates name and startup script", async ({ page, withProject }) => {
   await page.setViewportSize({ width: 1400, height: 800 })
   await page.setViewportSize({ width: 1400, height: 800 })
 
 
-  await withProject(async () => {
+  await withProject(async ({ slug }) => {
     await openSidebar(page)
     await openSidebar(page)
 
 
     const open = async () => {
     const open = async () => {
-      const header = page.locator(".group\\/project").first()
-      await header.hover()
-      const trigger = header.getByRole("button", { name: "More options" }).first()
-      await expect(trigger).toBeVisible()
-      await trigger.click({ force: true })
-
-      const menu = page.locator('[data-component="dropdown-menu-content"]').first()
-      await expect(menu).toBeVisible()
-
-      const editItem = menu.getByRole("menuitem", { name: "Edit" }).first()
-      await expect(editItem).toBeVisible()
-      await editItem.click({ force: true })
+      const menu = await openProjectMenu(page, slug)
+      await clickMenuItem(menu, /^Edit$/i, { force: true })
 
 
       const dialog = page.getByRole("dialog")
       const dialog = page.getByRole("dialog")
       await expect(dialog).toBeVisible()
       await expect(dialog).toBeVisible()

+ 1 - 29
packages/app/e2e/projects/projects-close.spec.ts

@@ -1,36 +1,8 @@
 import { test, expect } from "../fixtures"
 import { test, expect } from "../fixtures"
 import { createTestProject, cleanupTestProject, openSidebar, clickMenuItem, openProjectMenu } from "../actions"
 import { createTestProject, cleanupTestProject, openSidebar, clickMenuItem, openProjectMenu } from "../actions"
-import { projectCloseHoverSelector, projectSwitchSelector } from "../selectors"
+import { projectSwitchSelector } from "../selectors"
 import { dirSlug } from "../utils"
 import { dirSlug } from "../utils"
 
 
-test("can close a project via hover card close button", async ({ page, withProject }) => {
-  await page.setViewportSize({ width: 1400, height: 800 })
-
-  const other = await createTestProject()
-  const otherSlug = dirSlug(other)
-
-  try {
-    await withProject(
-      async () => {
-        await openSidebar(page)
-
-        const otherButton = page.locator(projectSwitchSelector(otherSlug)).first()
-        await expect(otherButton).toBeVisible()
-        await otherButton.hover()
-
-        const close = page.locator(projectCloseHoverSelector(otherSlug)).first()
-        await expect(close).toBeVisible()
-        await close.click()
-
-        await expect(otherButton).toHaveCount(0)
-      },
-      { extra: [other] },
-    )
-  } finally {
-    await cleanupTestProject(other)
-  }
-})
-
 test("closing active project navigates to another open project", async ({ page, withProject }) => {
 test("closing active project navigates to another open project", async ({ page, withProject }) => {
   await page.setViewportSize({ width: 1400, height: 800 })
   await page.setViewportSize({ width: 1400, height: 800 })
 
 

+ 57 - 57
packages/app/e2e/projects/projects-switch.spec.ts

@@ -1,18 +1,39 @@
 import { base64Decode } from "@opencode-ai/util/encode"
 import { base64Decode } from "@opencode-ai/util/encode"
+import type { Page } from "@playwright/test"
 import { test, expect } from "../fixtures"
 import { test, expect } from "../fixtures"
-import {
-  defocus,
-  createTestProject,
-  cleanupTestProject,
-  openSidebar,
-  setWorkspacesEnabled,
-  sessionIDFromUrl,
-} from "../actions"
+import { defocus, createTestProject, cleanupTestProject, openSidebar, sessionIDFromUrl, waitSlug } from "../actions"
 import { projectSwitchSelector, promptSelector, workspaceItemSelector, workspaceNewSessionSelector } from "../selectors"
 import { projectSwitchSelector, promptSelector, workspaceItemSelector, workspaceNewSessionSelector } from "../selectors"
-import { createSdk, dirSlug, sessionPath } from "../utils"
-
-function slugFromUrl(url: string) {
-  return /\/([^/]+)\/session(?:\/|$)/.exec(url)?.[1] ?? ""
+import { dirSlug, resolveDirectory } from "../utils"
+
+async function workspaces(page: Page, directory: string, enabled: boolean) {
+  await page.evaluate(
+    ({ directory, enabled }: { directory: string; enabled: boolean }) => {
+      const key = "opencode.global.dat:layout"
+      const raw = localStorage.getItem(key)
+      const data = raw ? JSON.parse(raw) : {}
+      const sidebar = data.sidebar && typeof data.sidebar === "object" ? data.sidebar : {}
+      const current =
+        sidebar.workspaces && typeof sidebar.workspaces === "object" && !Array.isArray(sidebar.workspaces)
+          ? sidebar.workspaces
+          : {}
+      const next = { ...current }
+
+      if (enabled) next[directory] = true
+      if (!enabled) delete next[directory]
+
+      localStorage.setItem(
+        key,
+        JSON.stringify({
+          ...data,
+          sidebar: {
+            ...sidebar,
+            workspaces: next,
+          },
+        }),
+      )
+    },
+    { directory, enabled },
+  )
 }
 }
 
 
 test("can switch between projects from sidebar", async ({ page, withProject }) => {
 test("can switch between projects from sidebar", async ({ page, withProject }) => {
@@ -51,46 +72,39 @@ test("switching back to a project opens the latest workspace session", async ({
 
 
   const other = await createTestProject()
   const other = await createTestProject()
   const otherSlug = dirSlug(other)
   const otherSlug = dirSlug(other)
-  let rootDir: string | undefined
-  let workspaceDir: string | undefined
-  let sessionID: string | undefined
-
   try {
   try {
     await withProject(
     await withProject(
-      async ({ directory, slug }) => {
-        rootDir = directory
+      async ({ directory, slug, trackSession, trackDirectory }) => {
         await defocus(page)
         await defocus(page)
+        await workspaces(page, directory, true)
+        await page.reload()
+        await expect(page.locator(promptSelector)).toBeVisible()
         await openSidebar(page)
         await openSidebar(page)
-        await setWorkspacesEnabled(page, slug, true)
+        await expect(page.getByRole("button", { name: "New workspace" }).first()).toBeVisible()
 
 
         await page.getByRole("button", { name: "New workspace" }).first().click()
         await page.getByRole("button", { name: "New workspace" }).first().click()
 
 
-        await expect
-          .poll(
-            () => {
-              const next = slugFromUrl(page.url())
-              if (!next) return ""
-              if (next === slug) return ""
-              return next
-            },
-            { timeout: 45_000 },
-          )
-          .not.toBe("")
-
-        const workspaceSlug = slugFromUrl(page.url())
-        workspaceDir = base64Decode(workspaceSlug)
-        if (!workspaceDir) throw new Error(`Failed to decode workspace slug: ${workspaceSlug}`)
+        const raw = await waitSlug(page, [slug])
+        const dir = base64Decode(raw)
+        if (!dir) throw new Error(`Failed to decode workspace slug: ${raw}`)
+        const space = await resolveDirectory(dir)
+        const next = dirSlug(space)
+        trackDirectory(space)
         await openSidebar(page)
         await openSidebar(page)
 
 
-        const workspace = page.locator(workspaceItemSelector(workspaceSlug)).first()
-        await expect(workspace).toBeVisible()
-        await workspace.hover()
+        const item = page.locator(`${workspaceItemSelector(next)}, ${workspaceItemSelector(raw)}`).first()
+        await expect(item).toBeVisible()
+        await item.hover()
 
 
-        const newSession = page.locator(workspaceNewSessionSelector(workspaceSlug)).first()
-        await expect(newSession).toBeVisible()
-        await newSession.click({ force: true })
+        const btn = page.locator(`${workspaceNewSessionSelector(next)}, ${workspaceNewSessionSelector(raw)}`).first()
+        await expect(btn).toBeVisible()
+        await btn.click({ force: true })
 
 
-        await expect(page).toHaveURL(new RegExp(`/${workspaceSlug}/session(?:[/?#]|$)`))
+        // A new workspace can be discovered via a transient slug before the route and sidebar
+        // settle to the canonical workspace path on Windows, so interact with either and assert
+        // against the resolved workspace slug.
+        await waitSlug(page)
+        await expect(page).toHaveURL(new RegExp(`/${next}/session(?:[/?#]|$)`))
 
 
         // Create a session by sending a prompt
         // Create a session by sending a prompt
         const prompt = page.locator(promptSelector)
         const prompt = page.locator(promptSelector)
@@ -103,9 +117,9 @@ test("switching back to a project opens the latest workspace session", async ({
 
 
         const created = sessionIDFromUrl(page.url())
         const created = sessionIDFromUrl(page.url())
         if (!created) throw new Error(`Failed to get session ID from url: ${page.url()}`)
         if (!created) throw new Error(`Failed to get session ID from url: ${page.url()}`)
-        sessionID = created
+        trackSession(created, space)
 
 
-        await expect(page).toHaveURL(new RegExp(`/${workspaceSlug}/session/${created}(?:[/?#]|$)`))
+        await expect(page).toHaveURL(new RegExp(`/${next}/session/${created}(?:[/?#]|$)`))
 
 
         await openSidebar(page)
         await openSidebar(page)
 
 
@@ -124,20 +138,6 @@ test("switching back to a project opens the latest workspace session", async ({
       { extra: [other] },
       { extra: [other] },
     )
     )
   } finally {
   } finally {
-    if (sessionID) {
-      const id = sessionID
-      const dirs = [rootDir, workspaceDir].filter((x): x is string => !!x)
-      await Promise.all(
-        dirs.map((directory) =>
-          createSdk(directory)
-            .session.delete({ sessionID: id })
-            .catch(() => undefined),
-        ),
-      )
-    }
-    if (workspaceDir) {
-      await cleanupTestProject(workspaceDir)
-    }
     await cleanupTestProject(other)
     await cleanupTestProject(other)
   }
   }
 })
 })

+ 33 - 68
packages/app/e2e/projects/workspace-new-session.spec.ts

@@ -1,14 +1,10 @@
 import { base64Decode } from "@opencode-ai/util/encode"
 import { base64Decode } from "@opencode-ai/util/encode"
 import type { Page } from "@playwright/test"
 import type { Page } from "@playwright/test"
 import { test, expect } from "../fixtures"
 import { test, expect } from "../fixtures"
-import { cleanupTestProject, openSidebar, sessionIDFromUrl, setWorkspacesEnabled } from "../actions"
+import { openSidebar, sessionIDFromUrl, setWorkspacesEnabled, slugFromUrl, waitSlug } from "../actions"
 import { promptSelector, workspaceItemSelector, workspaceNewSessionSelector } from "../selectors"
 import { promptSelector, workspaceItemSelector, workspaceNewSessionSelector } from "../selectors"
 import { createSdk } from "../utils"
 import { createSdk } from "../utils"
 
 
-function slugFromUrl(url: string) {
-  return /\/([^/]+)\/session(?:\/|$)/.exec(url)?.[1] ?? ""
-}
-
 async function waitWorkspaceReady(page: Page, slug: string) {
 async function waitWorkspaceReady(page: Page, slug: string) {
   await openSidebar(page)
   await openSidebar(page)
   await expect
   await expect
@@ -31,20 +27,7 @@ async function createWorkspace(page: Page, root: string, seen: string[]) {
   await openSidebar(page)
   await openSidebar(page)
   await page.getByRole("button", { name: "New workspace" }).first().click()
   await page.getByRole("button", { name: "New workspace" }).first().click()
 
 
-  await expect
-    .poll(
-      () => {
-        const slug = slugFromUrl(page.url())
-        if (!slug) return ""
-        if (slug === root) return ""
-        if (seen.includes(slug)) return ""
-        return slug
-      },
-      { timeout: 45_000 },
-    )
-    .not.toBe("")
-
-  const slug = slugFromUrl(page.url())
+  const slug = await waitSlug(page, [root, ...seen])
   const directory = base64Decode(slug)
   const directory = base64Decode(slug)
   if (!directory) throw new Error(`Failed to decode workspace slug: ${slug}`)
   if (!directory) throw new Error(`Failed to decode workspace slug: ${slug}`)
   return { slug, directory }
   return { slug, directory }
@@ -60,12 +43,13 @@ async function openWorkspaceNewSession(page: Page, slug: string) {
   await expect(button).toBeVisible()
   await expect(button).toBeVisible()
   await button.click({ force: true })
   await button.click({ force: true })
 
 
-  await expect.poll(() => slugFromUrl(page.url())).toBe(slug)
-  await expect(page).toHaveURL(new RegExp(`/${slug}/session(?:[/?#]|$)`))
+  const next = await waitSlug(page)
+  await expect(page).toHaveURL(new RegExp(`/${next}/session(?:[/?#]|$)`))
+  return next
 }
 }
 
 
 async function createSessionFromWorkspace(page: Page, slug: string, text: string) {
 async function createSessionFromWorkspace(page: Page, slug: string, text: string) {
-  await openWorkspaceNewSession(page, slug)
+  const next = await openWorkspaceNewSession(page, slug)
 
 
   const prompt = page.locator(promptSelector)
   const prompt = page.locator(promptSelector)
   await expect(prompt).toBeVisible()
   await expect(prompt).toBeVisible()
@@ -76,13 +60,13 @@ async function createSessionFromWorkspace(page: Page, slug: string, text: string
   await expect.poll(async () => ((await prompt.textContent()) ?? "").trim()).toContain(text)
   await expect.poll(async () => ((await prompt.textContent()) ?? "").trim()).toContain(text)
   await prompt.press("Enter")
   await prompt.press("Enter")
 
 
-  await expect.poll(() => slugFromUrl(page.url())).toBe(slug)
+  await expect.poll(() => slugFromUrl(page.url())).toBe(next)
   await expect.poll(() => sessionIDFromUrl(page.url()) ?? "", { timeout: 30_000 }).not.toBe("")
   await expect.poll(() => sessionIDFromUrl(page.url()) ?? "", { timeout: 30_000 }).not.toBe("")
 
 
   const sessionID = sessionIDFromUrl(page.url())
   const sessionID = sessionIDFromUrl(page.url())
   if (!sessionID) throw new Error(`Failed to parse session id from url: ${page.url()}`)
   if (!sessionID) throw new Error(`Failed to parse session id from url: ${page.url()}`)
-  await expect(page).toHaveURL(new RegExp(`/${slug}/session/${sessionID}(?:[/?#]|$)`))
-  return sessionID
+  await expect(page).toHaveURL(new RegExp(`/${next}/session/${sessionID}(?:[/?#]|$)`))
+  return { sessionID, slug: next }
 }
 }
 
 
 async function sessionDirectory(directory: string, sessionID: string) {
 async function sessionDirectory(directory: string, sessionID: string) {
@@ -97,48 +81,29 @@ async function sessionDirectory(directory: string, sessionID: string) {
 test("new sessions from sidebar workspace actions stay in selected workspace", async ({ page, withProject }) => {
 test("new sessions from sidebar workspace actions stay in selected workspace", async ({ page, withProject }) => {
   await page.setViewportSize({ width: 1400, height: 800 })
   await page.setViewportSize({ width: 1400, height: 800 })
 
 
-  await withProject(async ({ directory, slug: root }) => {
-    const workspaces = [] as { slug: string; directory: string }[]
-    const sessions = [] as string[]
-
-    try {
-      await openSidebar(page)
-      await setWorkspacesEnabled(page, root, true)
-
-      const first = await createWorkspace(page, root, [])
-      workspaces.push(first)
-      await waitWorkspaceReady(page, first.slug)
-
-      const second = await createWorkspace(page, root, [first.slug])
-      workspaces.push(second)
-      await waitWorkspaceReady(page, second.slug)
-
-      const firstSession = await createSessionFromWorkspace(page, first.slug, `workspace one ${Date.now()}`)
-      sessions.push(firstSession)
-
-      const secondSession = await createSessionFromWorkspace(page, second.slug, `workspace two ${Date.now()}`)
-      sessions.push(secondSession)
-
-      const thirdSession = await createSessionFromWorkspace(page, first.slug, `workspace one again ${Date.now()}`)
-      sessions.push(thirdSession)
-
-      await expect.poll(() => sessionDirectory(first.directory, firstSession)).toBe(first.directory)
-      await expect.poll(() => sessionDirectory(second.directory, secondSession)).toBe(second.directory)
-      await expect.poll(() => sessionDirectory(first.directory, thirdSession)).toBe(first.directory)
-    } finally {
-      const dirs = [directory, ...workspaces.map((workspace) => workspace.directory)]
-      await Promise.all(
-        sessions.map((sessionID) =>
-          Promise.all(
-            dirs.map((dir) =>
-              createSdk(dir)
-                .session.delete({ sessionID })
-                .catch(() => undefined),
-            ),
-          ),
-        ),
-      )
-      await Promise.all(workspaces.map((workspace) => cleanupTestProject(workspace.directory)))
-    }
+  await withProject(async ({ directory, slug: root, trackSession, trackDirectory }) => {
+    await openSidebar(page)
+    await setWorkspacesEnabled(page, root, true)
+
+    const first = await createWorkspace(page, root, [])
+    trackDirectory(first.directory)
+    await waitWorkspaceReady(page, first.slug)
+
+    const second = await createWorkspace(page, root, [first.slug])
+    trackDirectory(second.directory)
+    await waitWorkspaceReady(page, second.slug)
+
+    const firstSession = await createSessionFromWorkspace(page, first.slug, `workspace one ${Date.now()}`)
+    trackSession(firstSession.sessionID, first.directory)
+
+    const secondSession = await createSessionFromWorkspace(page, second.slug, `workspace two ${Date.now()}`)
+    trackSession(secondSession.sessionID, second.directory)
+
+    const thirdSession = await createSessionFromWorkspace(page, first.slug, `workspace one again ${Date.now()}`)
+    trackSession(thirdSession.sessionID, first.directory)
+
+    await expect.poll(() => sessionDirectory(first.directory, firstSession.sessionID)).toBe(first.directory)
+    await expect.poll(() => sessionDirectory(second.directory, secondSession.sessionID)).toBe(second.directory)
+    await expect.poll(() => sessionDirectory(first.directory, thirdSession.sessionID)).toBe(first.directory)
   })
   })
 })
 })

+ 6 - 42
packages/app/e2e/projects/workspaces.spec.ts

@@ -14,14 +14,12 @@ import {
   openSidebar,
   openSidebar,
   openWorkspaceMenu,
   openWorkspaceMenu,
   setWorkspacesEnabled,
   setWorkspacesEnabled,
+  slugFromUrl,
+  waitSlug,
 } from "../actions"
 } from "../actions"
 import { dropdownMenuContentSelector, inlineInputSelector, workspaceItemSelector } from "../selectors"
 import { dropdownMenuContentSelector, inlineInputSelector, workspaceItemSelector } from "../selectors"
 import { createSdk, dirSlug } from "../utils"
 import { createSdk, dirSlug } from "../utils"
 
 
-function slugFromUrl(url: string) {
-  return /\/([^/]+)\/session(?:\/|$)/.exec(url)?.[1] ?? ""
-}
-
 async function setupWorkspaceTest(page: Page, project: { slug: string }) {
 async function setupWorkspaceTest(page: Page, project: { slug: string }) {
   const rootSlug = project.slug
   const rootSlug = project.slug
   await openSidebar(page)
   await openSidebar(page)
@@ -29,17 +27,7 @@ async function setupWorkspaceTest(page: Page, project: { slug: string }) {
   await setWorkspacesEnabled(page, rootSlug, true)
   await setWorkspacesEnabled(page, rootSlug, true)
 
 
   await page.getByRole("button", { name: "New workspace" }).first().click()
   await page.getByRole("button", { name: "New workspace" }).first().click()
-  await expect
-    .poll(
-      () => {
-        const slug = slugFromUrl(page.url())
-        return slug.length > 0 && slug !== rootSlug
-      },
-      { timeout: 45_000 },
-    )
-    .toBe(true)
-
-  const slug = slugFromUrl(page.url())
+  const slug = await waitSlug(page, [rootSlug])
   const dir = base64Decode(slug)
   const dir = base64Decode(slug)
 
 
   await openSidebar(page)
   await openSidebar(page)
@@ -91,18 +79,7 @@ test("can create a workspace", async ({ page, withProject }) => {
     await expect(page.getByRole("button", { name: "New workspace" }).first()).toBeVisible()
     await expect(page.getByRole("button", { name: "New workspace" }).first()).toBeVisible()
 
 
     await page.getByRole("button", { name: "New workspace" }).first().click()
     await page.getByRole("button", { name: "New workspace" }).first().click()
-
-    await expect
-      .poll(
-        () => {
-          const currentSlug = slugFromUrl(page.url())
-          return currentSlug.length > 0 && currentSlug !== slug
-        },
-        { timeout: 45_000 },
-      )
-      .toBe(true)
-
-    const workspaceSlug = slugFromUrl(page.url())
+    const workspaceSlug = await waitSlug(page, [slug])
     const workspaceDir = base64Decode(workspaceSlug)
     const workspaceDir = base64Decode(workspaceSlug)
 
 
     await openSidebar(page)
     await openSidebar(page)
@@ -279,7 +256,7 @@ test("can delete a workspace", async ({ page, withProject }) => {
     await clickMenuItem(menu, /^Delete$/i, { force: true })
     await clickMenuItem(menu, /^Delete$/i, { force: true })
     await confirmDialog(page, /^Delete workspace$/i)
     await confirmDialog(page, /^Delete workspace$/i)
 
 
-    await expect(page).toHaveURL(new RegExp(`/${rootSlug}/session`))
+    await expect.poll(() => base64Decode(slugFromUrl(page.url()))).toBe(project.directory)
 
 
     await expect
     await expect
       .poll(
       .poll(
@@ -336,9 +313,6 @@ test("can reorder workspaces by drag and drop", async ({ page, withProject }) =>
       const src = page.locator(workspaceItemSelector(from)).first()
       const src = page.locator(workspaceItemSelector(from)).first()
       const dst = page.locator(workspaceItemSelector(to)).first()
       const dst = page.locator(workspaceItemSelector(to)).first()
 
 
-      await src.scrollIntoViewIfNeeded()
-      await dst.scrollIntoViewIfNeeded()
-
       const a = await src.boundingBox()
       const a = await src.boundingBox()
       const b = await dst.boundingBox()
       const b = await dst.boundingBox()
       if (!a || !b) throw new Error("Failed to resolve workspace drag bounds")
       if (!a || !b) throw new Error("Failed to resolve workspace drag bounds")
@@ -357,17 +331,7 @@ test("can reorder workspaces by drag and drop", async ({ page, withProject }) =>
       for (const _ of [0, 1]) {
       for (const _ of [0, 1]) {
         const prev = slugFromUrl(page.url())
         const prev = slugFromUrl(page.url())
         await page.getByRole("button", { name: "New workspace" }).first().click()
         await page.getByRole("button", { name: "New workspace" }).first().click()
-        await expect
-          .poll(
-            () => {
-              const slug = slugFromUrl(page.url())
-              return slug.length > 0 && slug !== rootSlug && slug !== prev
-            },
-            { timeout: 45_000 },
-          )
-          .toBe(true)
-
-        const slug = slugFromUrl(page.url())
+        const slug = await waitSlug(page, [rootSlug, prev])
         const dir = base64Decode(slug)
         const dir = base64Decode(slug)
         workspaces.push({ slug, directory: dir })
         workspaces.push({ slug, directory: dir })
 
 

+ 35 - 2
packages/app/e2e/prompt/prompt-async.spec.ts

@@ -1,6 +1,8 @@
 import { test, expect } from "../fixtures"
 import { test, expect } from "../fixtures"
 import { promptSelector } from "../selectors"
 import { promptSelector } from "../selectors"
-import { sessionIDFromUrl } from "../actions"
+import { cleanupSession, sessionIDFromUrl, withSession } from "../actions"
+
+const text = (value: string | null) => (value ?? "").replace(/\u200B/g, "").trim()
 
 
 // Regression test for Issue #12453: the synchronous POST /message endpoint holds
 // Regression test for Issue #12453: the synchronous POST /message endpoint holds
 // the connection open while the agent works, causing "Failed to fetch" over
 // the connection open while the agent works, causing "Failed to fetch" over
@@ -38,6 +40,37 @@ test("prompt succeeds when sync message endpoint is unreachable", async ({ page,
       )
       )
       .toContain(token)
       .toContain(token)
   } finally {
   } finally {
-    await sdk.session.delete({ sessionID }).catch(() => undefined)
+    await cleanupSession({ sdk, sessionID })
   }
   }
 })
 })
+
+test("failed prompt send restores the composer input", async ({ page, sdk, gotoSession }) => {
+  await withSession(sdk, `e2e prompt failure ${Date.now()}`, async (session) => {
+    const prompt = page.locator(promptSelector)
+    const value = `restore ${Date.now()}`
+
+    await page.route(`**/session/${session.id}/prompt_async`, (route) =>
+      route.fulfill({
+        status: 500,
+        contentType: "application/json",
+        body: JSON.stringify({ message: "e2e prompt failure" }),
+      }),
+    )
+
+    await gotoSession(session.id)
+    await prompt.click()
+    await page.keyboard.type(value)
+    await page.keyboard.press("Enter")
+
+    await expect.poll(async () => text(await prompt.textContent())).toBe(value)
+    await expect
+      .poll(
+        async () => {
+          const messages = await sdk.session.messages({ sessionID: session.id, limit: 50 }).then((r) => r.data ?? [])
+          return messages.length
+        },
+        { timeout: 15_000 },
+      )
+      .toBe(0)
+  })
+})

+ 181 - 0
packages/app/e2e/prompt/prompt-history.spec.ts

@@ -0,0 +1,181 @@
+import type { ToolPart } from "@opencode-ai/sdk/v2/client"
+import type { Page } from "@playwright/test"
+import { test, expect } from "../fixtures"
+import { withSession } from "../actions"
+import { promptSelector } from "../selectors"
+
+const text = (value: string | null) => (value ?? "").replace(/\u200B/g, "").trim()
+
+const isBash = (part: unknown): part is ToolPart => {
+  if (!part || typeof part !== "object") return false
+  if (!("type" in part) || part.type !== "tool") return false
+  if (!("tool" in part) || part.tool !== "bash") return false
+  return "state" in part
+}
+
+async function edge(page: Page, pos: "start" | "end") {
+  await page.locator(promptSelector).evaluate((el: HTMLDivElement, pos: "start" | "end") => {
+    const selection = window.getSelection()
+    if (!selection) return
+
+    const walk = document.createTreeWalker(el, NodeFilter.SHOW_TEXT)
+    const nodes: Text[] = []
+    for (let node = walk.nextNode(); node; node = walk.nextNode()) {
+      nodes.push(node as Text)
+    }
+
+    if (nodes.length === 0) {
+      const node = document.createTextNode("")
+      el.appendChild(node)
+      nodes.push(node)
+    }
+
+    const node = pos === "start" ? nodes[0]! : nodes[nodes.length - 1]!
+    const range = document.createRange()
+    range.setStart(node, pos === "start" ? 0 : (node.textContent ?? "").length)
+    range.collapse(true)
+    selection.removeAllRanges()
+    selection.addRange(range)
+  }, pos)
+}
+
+async function wait(page: Page, value: string) {
+  await expect.poll(async () => text(await page.locator(promptSelector).textContent())).toBe(value)
+}
+
+async function reply(sdk: Parameters<typeof withSession>[0], sessionID: string, token: string) {
+  await expect
+    .poll(
+      async () => {
+        const messages = await sdk.session.messages({ sessionID, limit: 50 }).then((r) => r.data ?? [])
+        return messages
+          .filter((item) => item.info.role === "assistant")
+          .flatMap((item) => item.parts)
+          .filter((item) => item.type === "text")
+          .map((item) => item.text)
+          .join("\n")
+      },
+      { timeout: 90_000 },
+    )
+    .toContain(token)
+}
+
+async function shell(sdk: Parameters<typeof withSession>[0], sessionID: string, cmd: string, token: string) {
+  await expect
+    .poll(
+      async () => {
+        const messages = await sdk.session.messages({ sessionID, limit: 50 }).then((r) => r.data ?? [])
+        const part = messages
+          .filter((item) => item.info.role === "assistant")
+          .flatMap((item) => item.parts)
+          .filter(isBash)
+          .find((item) => item.state.input?.command === cmd && item.state.status === "completed")
+
+        if (!part || part.state.status !== "completed") return
+        return typeof part.state.metadata?.output === "string" ? part.state.metadata.output : part.state.output
+      },
+      { timeout: 90_000 },
+    )
+    .toContain(token)
+}
+
+test("prompt history restores unsent draft with arrow navigation", async ({ page, sdk, gotoSession }) => {
+  test.setTimeout(120_000)
+
+  await withSession(sdk, `e2e prompt history ${Date.now()}`, async (session) => {
+    await gotoSession(session.id)
+
+    const prompt = page.locator(promptSelector)
+    const firstToken = `E2E_HISTORY_ONE_${Date.now()}`
+    const secondToken = `E2E_HISTORY_TWO_${Date.now()}`
+    const first = `Reply with exactly: ${firstToken}`
+    const second = `Reply with exactly: ${secondToken}`
+    const draft = `draft ${Date.now()}`
+
+    await prompt.click()
+    await page.keyboard.type(first)
+    await page.keyboard.press("Enter")
+    await wait(page, "")
+    await reply(sdk, session.id, firstToken)
+
+    await prompt.click()
+    await page.keyboard.type(second)
+    await page.keyboard.press("Enter")
+    await wait(page, "")
+    await reply(sdk, session.id, secondToken)
+
+    await prompt.click()
+    await page.keyboard.type(draft)
+    await wait(page, draft)
+
+    await edge(page, "start")
+    await page.keyboard.press("ArrowUp")
+    await wait(page, second)
+
+    await page.keyboard.press("ArrowUp")
+    await wait(page, first)
+
+    await page.keyboard.press("ArrowDown")
+    await wait(page, second)
+
+    await page.keyboard.press("ArrowDown")
+    await wait(page, draft)
+  })
+})
+
+test("shell history stays separate from normal prompt history", async ({ page, sdk, gotoSession }) => {
+  test.setTimeout(120_000)
+
+  await withSession(sdk, `e2e shell history ${Date.now()}`, async (session) => {
+    await gotoSession(session.id)
+
+    const prompt = page.locator(promptSelector)
+    const firstToken = `E2E_SHELL_ONE_${Date.now()}`
+    const secondToken = `E2E_SHELL_TWO_${Date.now()}`
+    const normalToken = `E2E_NORMAL_${Date.now()}`
+    const first = `echo ${firstToken}`
+    const second = `echo ${secondToken}`
+    const normal = `Reply with exactly: ${normalToken}`
+
+    await prompt.click()
+    await page.keyboard.type("!")
+    await page.keyboard.type(first)
+    await page.keyboard.press("Enter")
+    await wait(page, "")
+    await shell(sdk, session.id, first, firstToken)
+
+    await prompt.click()
+    await page.keyboard.type("!")
+    await page.keyboard.type(second)
+    await page.keyboard.press("Enter")
+    await wait(page, "")
+    await shell(sdk, session.id, second, secondToken)
+
+    await prompt.click()
+    await page.keyboard.type("!")
+    await page.keyboard.press("ArrowUp")
+    await wait(page, second)
+
+    await page.keyboard.press("ArrowUp")
+    await wait(page, first)
+
+    await page.keyboard.press("ArrowDown")
+    await wait(page, second)
+
+    await page.keyboard.press("ArrowDown")
+    await wait(page, "")
+
+    await page.keyboard.press("Escape")
+    await wait(page, "")
+
+    await prompt.click()
+    await page.keyboard.type(normal)
+    await page.keyboard.press("Enter")
+    await wait(page, "")
+    await reply(sdk, session.id, normalToken)
+
+    await prompt.click()
+    await page.keyboard.press("ArrowUp")
+    await wait(page, normal)
+  })
+})

+ 62 - 0
packages/app/e2e/prompt/prompt-shell.spec.ts

@@ -0,0 +1,62 @@
+import type { ToolPart } from "@opencode-ai/sdk/v2/client"
+import { test, expect } from "../fixtures"
+import { sessionIDFromUrl } from "../actions"
+import { promptSelector } from "../selectors"
+import { createSdk } from "../utils"
+
+const isBash = (part: unknown): part is ToolPart => {
+  if (!part || typeof part !== "object") return false
+  if (!("type" in part) || part.type !== "tool") return false
+  if (!("tool" in part) || part.tool !== "bash") return false
+  return "state" in part
+}
+
+test("shell mode runs a command in the project directory", async ({ page, withProject }) => {
+  test.setTimeout(120_000)
+
+  await withProject(async ({ directory, gotoSession, trackSession }) => {
+    const sdk = createSdk(directory)
+    const prompt = page.locator(promptSelector)
+    const cmd = process.platform === "win32" ? "dir" : "ls"
+
+    await gotoSession()
+    await prompt.click()
+    await page.keyboard.type("!")
+    await expect(prompt).toHaveAttribute("aria-label", /enter shell command/i)
+
+    await page.keyboard.type(cmd)
+    await page.keyboard.press("Enter")
+
+    await expect(page).toHaveURL(/\/session\/[^/?#]+/, { timeout: 30_000 })
+
+    const id = sessionIDFromUrl(page.url())
+    if (!id) throw new Error(`Failed to parse session id from url: ${page.url()}`)
+    trackSession(id, directory)
+
+    await expect
+      .poll(
+        async () => {
+          const list = await sdk.session.messages({ sessionID: id, limit: 50 }).then((x) => x.data ?? [])
+          const msg = list.findLast(
+            (item) => item.info.role === "assistant" && "path" in item.info && item.info.path.cwd === directory,
+          )
+          if (!msg) return
+
+          const part = msg.parts
+            .filter(isBash)
+            .find((item) => item.state.input?.command === cmd && item.state.status === "completed")
+
+          if (!part || part.state.status !== "completed") return
+          const output =
+            typeof part.state.metadata?.output === "string" ? part.state.metadata.output : part.state.output
+          if (!output.includes("README.md")) return
+
+          return { cwd: directory, output }
+        },
+        { timeout: 90_000 },
+      )
+      .toEqual(expect.objectContaining({ cwd: directory, output: expect.stringContaining("README.md") }))
+
+    await expect(prompt).toHaveText("")
+  })
+})

+ 64 - 0
packages/app/e2e/prompt/prompt-slash-share.spec.ts

@@ -0,0 +1,64 @@
+import { test, expect } from "../fixtures"
+import { promptSelector } from "../selectors"
+import { withSession } from "../actions"
+
+const shareDisabled = process.env.OPENCODE_DISABLE_SHARE === "true" || process.env.OPENCODE_DISABLE_SHARE === "1"
+
+async function seed(sdk: Parameters<typeof withSession>[0], sessionID: string) {
+  await sdk.session.promptAsync({
+    sessionID,
+    noReply: true,
+    parts: [{ type: "text", text: "e2e share seed" }],
+  })
+
+  await expect
+    .poll(
+      async () => {
+        const messages = await sdk.session.messages({ sessionID, limit: 1 }).then((r) => r.data ?? [])
+        return messages.length
+      },
+      { timeout: 30_000 },
+    )
+    .toBeGreaterThan(0)
+}
+
+test("/share and /unshare update session share state", async ({ page, sdk, gotoSession }) => {
+  test.skip(shareDisabled, "Share is disabled in this environment (OPENCODE_DISABLE_SHARE).")
+
+  await withSession(sdk, `e2e slash share ${Date.now()}`, async (session) => {
+    const prompt = page.locator(promptSelector)
+
+    await seed(sdk, session.id)
+    await gotoSession(session.id)
+
+    await prompt.click()
+    await page.keyboard.type("/share")
+    await expect(page.locator('[data-slash-id="session.share"]').first()).toBeVisible()
+    await page.keyboard.press("Enter")
+
+    await expect
+      .poll(
+        async () => {
+          const data = await sdk.session.get({ sessionID: session.id }).then((r) => r.data)
+          return data?.share?.url || undefined
+        },
+        { timeout: 30_000 },
+      )
+      .not.toBeUndefined()
+
+    await prompt.click()
+    await page.keyboard.type("/unshare")
+    await expect(page.locator('[data-slash-id="session.unshare"]').first()).toBeVisible()
+    await page.keyboard.press("Enter")
+
+    await expect
+      .poll(
+        async () => {
+          const data = await sdk.session.get({ sessionID: session.id }).then((r) => r.data)
+          return data?.share?.url || undefined
+        },
+        { timeout: 30_000 },
+      )
+      .toBeUndefined()
+  })
+})

+ 2 - 2
packages/app/e2e/prompt/prompt.spec.ts

@@ -1,6 +1,6 @@
 import { test, expect } from "../fixtures"
 import { test, expect } from "../fixtures"
 import { promptSelector } from "../selectors"
 import { promptSelector } from "../selectors"
-import { sessionIDFromUrl, withSession } from "../actions"
+import { cleanupSession, sessionIDFromUrl, withSession } from "../actions"
 
 
 test("can send a prompt and receive a reply", async ({ page, sdk, gotoSession }) => {
 test("can send a prompt and receive a reply", async ({ page, sdk, gotoSession }) => {
   test.setTimeout(120_000)
   test.setTimeout(120_000)
@@ -46,7 +46,7 @@ test("can send a prompt and receive a reply", async ({ page, sdk, gotoSession })
       .toContain(token)
       .toContain(token)
   } finally {
   } finally {
     page.off("pageerror", onPageError)
     page.off("pageerror", onPageError)
-    await sdk.session.delete({ sessionID }).catch(() => undefined)
+    await cleanupSession({ sdk, sessionID })
   }
   }
 
 
   if (pageErrors.length > 0) {
   if (pageErrors.length > 0) {

+ 2 - 2
packages/app/e2e/selectors.ts

@@ -30,8 +30,6 @@ export const sidebarNavSelector = '[data-component="sidebar-nav-desktop"]'
 export const projectSwitchSelector = (slug: string) =>
 export const projectSwitchSelector = (slug: string) =>
   `${sidebarNavSelector} [data-action="project-switch"][data-project="${slug}"]`
   `${sidebarNavSelector} [data-action="project-switch"][data-project="${slug}"]`
 
 
-export const projectCloseHoverSelector = (slug: string) => `[data-action="project-close-hover"][data-project="${slug}"]`
-
 export const projectMenuTriggerSelector = (slug: string) =>
 export const projectMenuTriggerSelector = (slug: string) =>
   `${sidebarNavSelector} [data-action="project-menu"][data-project="${slug}"]`
   `${sidebarNavSelector} [data-action="project-menu"][data-project="${slug}"]`
 
 
@@ -53,6 +51,8 @@ export const dropdownMenuContentSelector = '[data-component="dropdown-menu-conte
 
 
 export const inlineInputSelector = '[data-component="inline-input"]'
 export const inlineInputSelector = '[data-component="inline-input"]'
 
 
+export const sessionTimelineHeaderSelector = "[data-session-title]"
+
 export const sessionItemSelector = (sessionID: string) => `${sidebarNavSelector} [data-session-id="${sessionID}"]`
 export const sessionItemSelector = (sessionID: string) => `${sidebarNavSelector} [data-session-id="${sessionID}"]`
 
 
 export const workspaceItemSelector = (slug: string) =>
 export const workspaceItemSelector = (slug: string) =>

+ 37 - 0
packages/app/e2e/session/session-child-navigation.spec.ts

@@ -0,0 +1,37 @@
+import { seedSessionTask, withSession } from "../actions"
+import { test, expect } from "../fixtures"
+
+test("task tool child-session link does not trigger stale show errors", async ({ page, sdk, gotoSession }) => {
+  test.setTimeout(120_000)
+
+  const errs: string[] = []
+  const onError = (err: Error) => {
+    errs.push(err.message)
+  }
+  page.on("pageerror", onError)
+
+  await withSession(sdk, `e2e child nav ${Date.now()}`, async (session) => {
+    const child = await seedSessionTask(sdk, {
+      sessionID: session.id,
+      description: "Open child session",
+      prompt: "Search the repository for AssistantParts and then reply with exactly CHILD_OK.",
+    })
+
+    try {
+      await gotoSession(session.id)
+
+      const link = page
+        .locator("a.subagent-link")
+        .filter({ hasText: /open child session/i })
+        .first()
+      await expect(link).toBeVisible({ timeout: 30_000 })
+      await link.click()
+
+      await expect(page).toHaveURL(new RegExp(`/session/${child.sessionID}(?:[/?#]|$)`), { timeout: 30_000 })
+      await page.waitForTimeout(1000)
+      expect(errs).toEqual([])
+    } finally {
+      page.off("pageerror", onError)
+    }
+  })
+})

+ 4 - 4
packages/app/e2e/session/session-composer-dock.spec.ts

@@ -1,5 +1,5 @@
 import { test, expect } from "../fixtures"
 import { test, expect } from "../fixtures"
-import { clearSessionDockSeed, seedSessionQuestion, seedSessionTodos } from "../actions"
+import { cleanupSession, clearSessionDockSeed, seedSessionQuestion, seedSessionTodos } from "../actions"
 import {
 import {
   permissionDockSelector,
   permissionDockSelector,
   promptSelector,
   promptSelector,
@@ -26,7 +26,7 @@ async function withDockSession<T>(
   try {
   try {
     return await fn(session)
     return await fn(session)
   } finally {
   } finally {
-    await sdk.session.delete({ sessionID: session.id }).catch(() => undefined)
+    await cleanupSession({ sdk, sessionID: session.id })
   }
   }
 }
 }
 
 
@@ -311,7 +311,7 @@ test("child session question request blocks parent dock and unblocks after submi
         await expect(page.locator(promptSelector)).toBeVisible()
         await expect(page.locator(promptSelector)).toBeVisible()
       })
       })
     } finally {
     } finally {
-      await sdk.session.delete({ sessionID: child.id }).catch(() => undefined)
+      await cleanupSession({ sdk, sessionID: child.id })
     }
     }
   })
   })
 })
 })
@@ -358,7 +358,7 @@ test("child session permission request blocks parent dock and supports allow onc
         },
         },
       )
       )
     } finally {
     } finally {
-      await sdk.session.delete({ sessionID: child.id }).catch(() => undefined)
+      await cleanupSession({ sdk, sessionID: child.id })
     }
     }
   })
   })
 })
 })

+ 8 - 8
packages/app/e2e/session/session-undo-redo.spec.ts

@@ -45,7 +45,7 @@ async function seedConversation(input: {
     .toBe(true)
     .toBe(true)
 
 
   if (!userMessageID) throw new Error("Expected a user message id")
   if (!userMessageID) throw new Error("Expected a user message id")
-  await expect(input.page.locator(`[data-message-id="${userMessageID}"]`).first()).toBeVisible({ timeout: 30_000 })
+  await expect(input.page.locator(`[data-message-id="${userMessageID}"]`)).toHaveCount(1, { timeout: 30_000 })
   return { prompt, userMessageID }
   return { prompt, userMessageID }
 }
 }
 
 
@@ -123,7 +123,7 @@ test("slash redo clears revert and restores latest state", async ({ page, withPr
         .toBeUndefined()
         .toBeUndefined()
 
 
       await expect(seeded.prompt).not.toContainText(token)
       await expect(seeded.prompt).not.toContainText(token)
-      await expect(page.locator(`[data-message-id="${seeded.userMessageID}"]`).first()).toBeVisible()
+      await expect(page.locator(`[data-message-id="${seeded.userMessageID}"]`)).toHaveCount(1)
     })
     })
   })
   })
 })
 })
@@ -158,8 +158,8 @@ test("slash undo/redo traverses multi-step revert stack", async ({ page, withPro
       const firstMessage = page.locator(`[data-message-id="${first.userMessageID}"]`)
       const firstMessage = page.locator(`[data-message-id="${first.userMessageID}"]`)
       const secondMessage = page.locator(`[data-message-id="${second.userMessageID}"]`)
       const secondMessage = page.locator(`[data-message-id="${second.userMessageID}"]`)
 
 
-      await expect(firstMessage.first()).toBeVisible()
-      await expect(secondMessage.first()).toBeVisible()
+      await expect(firstMessage).toHaveCount(1)
+      await expect(secondMessage).toHaveCount(1)
 
 
       await second.prompt.click()
       await second.prompt.click()
       await page.keyboard.press(`${modKey}+A`)
       await page.keyboard.press(`${modKey}+A`)
@@ -176,7 +176,7 @@ test("slash undo/redo traverses multi-step revert stack", async ({ page, withPro
         })
         })
         .toBe(second.userMessageID)
         .toBe(second.userMessageID)
 
 
-      await expect(firstMessage.first()).toBeVisible()
+      await expect(firstMessage).toHaveCount(1)
       await expect(secondMessage).toHaveCount(0)
       await expect(secondMessage).toHaveCount(0)
 
 
       await second.prompt.click()
       await second.prompt.click()
@@ -210,7 +210,7 @@ test("slash undo/redo traverses multi-step revert stack", async ({ page, withPro
         })
         })
         .toBe(second.userMessageID)
         .toBe(second.userMessageID)
 
 
-      await expect(firstMessage.first()).toBeVisible()
+      await expect(firstMessage).toHaveCount(1)
       await expect(secondMessage).toHaveCount(0)
       await expect(secondMessage).toHaveCount(0)
 
 
       await second.prompt.click()
       await second.prompt.click()
@@ -226,8 +226,8 @@ test("slash undo/redo traverses multi-step revert stack", async ({ page, withPro
         })
         })
         .toBeUndefined()
         .toBeUndefined()
 
 
-      await expect(firstMessage.first()).toBeVisible()
-      await expect(secondMessage.first()).toBeVisible()
+      await expect(firstMessage).toHaveCount(1)
+      await expect(secondMessage).toHaveCount(1)
     })
     })
   })
   })
 })
 })

+ 8 - 4
packages/app/e2e/session/session.spec.ts

@@ -7,7 +7,7 @@ import {
   openSharePopover,
   openSharePopover,
   withSession,
   withSession,
 } from "../actions"
 } from "../actions"
-import { sessionItemSelector, inlineInputSelector } from "../selectors"
+import { sessionItemSelector, inlineInputSelector, sessionTimelineHeaderSelector } from "../selectors"
 
 
 const shareDisabled = process.env.OPENCODE_DISABLE_SHARE === "true" || process.env.OPENCODE_DISABLE_SHARE === "1"
 const shareDisabled = process.env.OPENCODE_DISABLE_SHARE === "true" || process.env.OPENCODE_DISABLE_SHARE === "1"
 
 
@@ -39,12 +39,14 @@ test("session can be renamed via header menu", async ({ page, sdk, gotoSession }
   await withSession(sdk, originalTitle, async (session) => {
   await withSession(sdk, originalTitle, async (session) => {
     await seedMessage(sdk, session.id)
     await seedMessage(sdk, session.id)
     await gotoSession(session.id)
     await gotoSession(session.id)
-    await expect(page.getByRole("heading", { level: 1 }).first()).toHaveText(originalTitle)
+    await expect(page.locator(sessionTimelineHeaderSelector).getByRole("heading", { level: 1 }).first()).toHaveText(
+      originalTitle,
+    )
 
 
     const menu = await openSessionMoreMenu(page, session.id)
     const menu = await openSessionMoreMenu(page, session.id)
     await clickMenuItem(menu, /rename/i)
     await clickMenuItem(menu, /rename/i)
 
 
-    const input = page.locator(".scroll-view__viewport").locator(inlineInputSelector).first()
+    const input = page.locator(sessionTimelineHeaderSelector).locator(inlineInputSelector).first()
     await expect(input).toBeVisible()
     await expect(input).toBeVisible()
     await expect(input).toBeFocused()
     await expect(input).toBeFocused()
     await input.fill(renamedTitle)
     await input.fill(renamedTitle)
@@ -61,7 +63,9 @@ test("session can be renamed via header menu", async ({ page, sdk, gotoSession }
       )
       )
       .toBe(renamedTitle)
       .toBe(renamedTitle)
 
 
-    await expect(page.getByRole("heading", { level: 1 }).first()).toHaveText(renamedTitle)
+    await expect(page.locator(sessionTimelineHeaderSelector).getByRole("heading", { level: 1 }).first()).toHaveText(
+      renamedTitle,
+    )
   })
   })
 })
 })
 
 

+ 6 - 9
packages/app/e2e/settings/settings-keybinds.spec.ts

@@ -32,22 +32,19 @@ test("changing sidebar toggle keybind works", async ({ page, gotoSession }) => {
 
 
   await closeDialog(page, dialog)
   await closeDialog(page, dialog)
 
 
-  const main = page.locator("main")
-  const initialClasses = (await main.getAttribute("class")) ?? ""
-  const initiallyClosed = initialClasses.includes("xl:border-l")
+  const button = page.getByRole("button", { name: /toggle sidebar/i }).first()
+  const initiallyClosed = (await button.getAttribute("aria-expanded")) !== "true"
 
 
   await page.keyboard.press(`${modKey}+Shift+H`)
   await page.keyboard.press(`${modKey}+Shift+H`)
-  await page.waitForTimeout(100)
+  await expect(button).toHaveAttribute("aria-expanded", initiallyClosed ? "true" : "false")
 
 
-  const afterToggleClasses = (await main.getAttribute("class")) ?? ""
-  const afterToggleClosed = afterToggleClasses.includes("xl:border-l")
+  const afterToggleClosed = (await button.getAttribute("aria-expanded")) !== "true"
   expect(afterToggleClosed).toBe(!initiallyClosed)
   expect(afterToggleClosed).toBe(!initiallyClosed)
 
 
   await page.keyboard.press(`${modKey}+Shift+H`)
   await page.keyboard.press(`${modKey}+Shift+H`)
-  await page.waitForTimeout(100)
+  await expect(button).toHaveAttribute("aria-expanded", initiallyClosed ? "false" : "true")
 
 
-  const finalClasses = (await main.getAttribute("class")) ?? ""
-  const finalClosed = finalClasses.includes("xl:border-l")
+  const finalClosed = (await button.getAttribute("aria-expanded")) !== "true"
   expect(finalClosed).toBe(initiallyClosed)
   expect(finalClosed).toBe(initiallyClosed)
 })
 })
 
 

+ 10 - 7
packages/app/e2e/sidebar/sidebar-popover-actions.spec.ts

@@ -1,6 +1,6 @@
 import { test, expect } from "../fixtures"
 import { test, expect } from "../fixtures"
-import { closeSidebar, hoverSessionItem } from "../actions"
-import { projectSwitchSelector, sessionItemSelector } from "../selectors"
+import { cleanupSession, closeSidebar, hoverSessionItem } from "../actions"
+import { projectSwitchSelector } from "../selectors"
 
 
 test("collapsed sidebar popover stays open when archiving a session", async ({ page, slug, sdk, gotoSession }) => {
 test("collapsed sidebar popover stays open when archiving a session", async ({ page, slug, sdk, gotoSession }) => {
   const stamp = Date.now()
   const stamp = Date.now()
@@ -15,12 +15,15 @@ test("collapsed sidebar popover stays open when archiving a session", async ({ p
     await gotoSession(one.id)
     await gotoSession(one.id)
     await closeSidebar(page)
     await closeSidebar(page)
 
 
+    const oneItem = page.locator(`[data-session-id="${one.id}"]`).last()
+    const twoItem = page.locator(`[data-session-id="${two.id}"]`).last()
+
     const project = page.locator(projectSwitchSelector(slug)).first()
     const project = page.locator(projectSwitchSelector(slug)).first()
     await expect(project).toBeVisible()
     await expect(project).toBeVisible()
     await project.hover()
     await project.hover()
 
 
-    await expect(page.locator(sessionItemSelector(one.id)).first()).toBeVisible()
-    await expect(page.locator(sessionItemSelector(two.id)).first()).toBeVisible()
+    await expect(oneItem).toBeVisible()
+    await expect(twoItem).toBeVisible()
 
 
     const item = await hoverSessionItem(page, one.id)
     const item = await hoverSessionItem(page, one.id)
     await item
     await item
@@ -28,9 +31,9 @@ test("collapsed sidebar popover stays open when archiving a session", async ({ p
       .first()
       .first()
       .click()
       .click()
 
 
-    await expect(page.locator(sessionItemSelector(two.id)).first()).toBeVisible()
+    await expect(twoItem).toBeVisible()
   } finally {
   } finally {
-    await sdk.session.delete({ sessionID: one.id }).catch(() => undefined)
-    await sdk.session.delete({ sessionID: two.id }).catch(() => undefined)
+    await cleanupSession({ sdk, sessionID: one.id })
+    await cleanupSession({ sdk, sessionID: two.id })
   }
   }
 })
 })

+ 3 - 4
packages/app/e2e/sidebar/sidebar-session-links.spec.ts

@@ -1,5 +1,5 @@
 import { test, expect } from "../fixtures"
 import { test, expect } from "../fixtures"
-import { openSidebar, withSession } from "../actions"
+import { cleanupSession, openSidebar, withSession } from "../actions"
 import { promptSelector } from "../selectors"
 import { promptSelector } from "../selectors"
 
 
 test("sidebar session links navigate to the selected session", async ({ page, slug, sdk, gotoSession }) => {
 test("sidebar session links navigate to the selected session", async ({ page, slug, sdk, gotoSession }) => {
@@ -18,14 +18,13 @@ test("sidebar session links navigate to the selected session", async ({ page, sl
 
 
     const target = page.locator(`[data-session-id="${two.id}"] a`).first()
     const target = page.locator(`[data-session-id="${two.id}"] a`).first()
     await expect(target).toBeVisible()
     await expect(target).toBeVisible()
-    await target.scrollIntoViewIfNeeded()
     await target.click()
     await target.click()
 
 
     await expect(page).toHaveURL(new RegExp(`/${slug}/session/${two.id}(?:\\?|#|$)`))
     await expect(page).toHaveURL(new RegExp(`/${slug}/session/${two.id}(?:\\?|#|$)`))
     await expect(page.locator(promptSelector)).toBeVisible()
     await expect(page.locator(promptSelector)).toBeVisible()
     await expect(page.locator(`[data-session-id="${two.id}"] a`).first()).toHaveClass(/\bactive\b/)
     await expect(page.locator(`[data-session-id="${two.id}"] a`).first()).toHaveClass(/\bactive\b/)
   } finally {
   } finally {
-    await sdk.session.delete({ sessionID: one.id }).catch(() => undefined)
-    await sdk.session.delete({ sessionID: two.id }).catch(() => undefined)
+    await cleanupSession({ sdk, sessionID: one.id })
+    await cleanupSession({ sdk, sessionID: two.id })
   }
   }
 })
 })

+ 8 - 5
packages/app/e2e/sidebar/sidebar.spec.ts

@@ -5,12 +5,14 @@ test("sidebar can be collapsed and expanded", async ({ page, gotoSession }) => {
   await gotoSession()
   await gotoSession()
 
 
   await openSidebar(page)
   await openSidebar(page)
+  const button = page.getByRole("button", { name: /toggle sidebar/i }).first()
+  await expect(button).toHaveAttribute("aria-expanded", "true")
 
 
   await toggleSidebar(page)
   await toggleSidebar(page)
-  await expect(page.locator("main")).toHaveClass(/xl:border-l/)
+  await expect(button).toHaveAttribute("aria-expanded", "false")
 
 
   await toggleSidebar(page)
   await toggleSidebar(page)
-  await expect(page.locator("main")).not.toHaveClass(/xl:border-l/)
+  await expect(button).toHaveAttribute("aria-expanded", "true")
 })
 })
 
 
 test("sidebar collapsed state persists across navigation and reload", async ({ page, sdk, gotoSession }) => {
 test("sidebar collapsed state persists across navigation and reload", async ({ page, sdk, gotoSession }) => {
@@ -19,14 +21,15 @@ test("sidebar collapsed state persists across navigation and reload", async ({ p
       await gotoSession(session1.id)
       await gotoSession(session1.id)
 
 
       await openSidebar(page)
       await openSidebar(page)
+      const button = page.getByRole("button", { name: /toggle sidebar/i }).first()
       await toggleSidebar(page)
       await toggleSidebar(page)
-      await expect(page.locator("main")).toHaveClass(/xl:border-l/)
+      await expect(button).toHaveAttribute("aria-expanded", "false")
 
 
       await gotoSession(session2.id)
       await gotoSession(session2.id)
-      await expect(page.locator("main")).toHaveClass(/xl:border-l/)
+      await expect(button).toHaveAttribute("aria-expanded", "false")
 
 
       await page.reload()
       await page.reload()
-      await expect(page.locator("main")).toHaveClass(/xl:border-l/)
+      await expect(button).toHaveAttribute("aria-expanded", "false")
 
 
       const opened = await page.evaluate(
       const opened = await page.evaluate(
         () => JSON.parse(localStorage.getItem("opencode.global.dat:layout") ?? "{}").sidebar?.opened,
         () => JSON.parse(localStorage.getItem("opencode.global.dat:layout") ?? "{}").sidebar?.opened,

+ 120 - 0
packages/app/e2e/terminal/terminal-tabs.spec.ts

@@ -0,0 +1,120 @@
+import type { Page } from "@playwright/test"
+import { test, expect } from "../fixtures"
+import { terminalSelector } from "../selectors"
+import { terminalToggleKey, workspacePersistKey } from "../utils"
+
+type State = {
+  active?: string
+  all: Array<{
+    id: string
+    title: string
+    titleNumber: number
+    buffer?: string
+  }>
+}
+
+async function open(page: Page) {
+  const terminal = page.locator(terminalSelector)
+  const visible = await terminal.isVisible().catch(() => false)
+  if (!visible) await page.keyboard.press(terminalToggleKey)
+  await expect(terminal).toBeVisible()
+  await expect(terminal.locator("textarea")).toHaveCount(1)
+}
+
+async function run(page: Page, cmd: string) {
+  const terminal = page.locator(terminalSelector)
+  await expect(terminal).toBeVisible()
+  await terminal.click()
+  await page.keyboard.type(cmd)
+  await page.keyboard.press("Enter")
+}
+
+async function store(page: Page, key: string) {
+  return page.evaluate((key) => {
+    const raw = localStorage.getItem(key)
+    if (raw) return JSON.parse(raw) as State
+
+    for (let i = 0; i < localStorage.length; i++) {
+      const next = localStorage.key(i)
+      if (!next?.endsWith(":workspace:terminal")) continue
+      const value = localStorage.getItem(next)
+      if (!value) continue
+      return JSON.parse(value) as State
+    }
+  }, key)
+}
+
+test("terminal tab buffers persist across tab switches", async ({ page, withProject }) => {
+  await withProject(async ({ directory, gotoSession }) => {
+    const key = workspacePersistKey(directory, "terminal")
+    const one = `E2E_TERM_ONE_${Date.now()}`
+    const two = `E2E_TERM_TWO_${Date.now()}`
+    const tabs = page.locator('#terminal-panel [data-slot="tabs-trigger"]')
+
+    await gotoSession()
+    await open(page)
+
+    await run(page, `echo ${one}`)
+
+    await page.getByRole("button", { name: /new terminal/i }).click()
+    await expect(tabs).toHaveCount(2)
+
+    await run(page, `echo ${two}`)
+
+    await tabs
+      .filter({ hasText: /Terminal 1/ })
+      .first()
+      .click()
+
+    await expect
+      .poll(
+        async () => {
+          const state = await store(page, key)
+          const first = state?.all.find((item) => item.titleNumber === 1)?.buffer ?? ""
+          const second = state?.all.find((item) => item.titleNumber === 2)?.buffer ?? ""
+          return first.includes(one) && second.includes(two)
+        },
+        { timeout: 30_000 },
+      )
+      .toBe(true)
+  })
+})
+
+test("closing the active terminal tab falls back to the previous tab", async ({ page, withProject }) => {
+  await withProject(async ({ directory, gotoSession }) => {
+    const key = workspacePersistKey(directory, "terminal")
+    const tabs = page.locator('#terminal-panel [data-slot="tabs-trigger"]')
+
+    await gotoSession()
+    await open(page)
+
+    await page.getByRole("button", { name: /new terminal/i }).click()
+    await expect(tabs).toHaveCount(2)
+
+    const second = tabs.filter({ hasText: /Terminal 2/ }).first()
+    await second.click()
+    await expect(second).toHaveAttribute("aria-selected", "true")
+
+    await second.hover()
+    await page
+      .getByRole("button", { name: /close terminal/i })
+      .nth(1)
+      .click({ force: true })
+
+    const first = tabs.filter({ hasText: /Terminal 1/ }).first()
+    await expect(tabs).toHaveCount(1)
+    await expect(first).toHaveAttribute("aria-selected", "true")
+    await expect
+      .poll(
+        async () => {
+          const state = await store(page, key)
+          return {
+            count: state?.all.length ?? 0,
+            first: state?.all.some((item) => item.titleNumber === 1) ?? false,
+          }
+        },
+        { timeout: 15_000 },
+      )
+      .toEqual({ count: 1, first: true })
+  })
+})

+ 29 - 1
packages/app/e2e/utils.ts

@@ -1,5 +1,5 @@
 import { createOpencodeClient } from "@opencode-ai/sdk/v2/client"
 import { createOpencodeClient } from "@opencode-ai/sdk/v2/client"
-import { base64Encode } from "@opencode-ai/util/encode"
+import { base64Encode, checksum } from "@opencode-ai/util/encode"
 
 
 export const serverHost = process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"
 export const serverHost = process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"
 export const serverPort = process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"
 export const serverPort = process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"
@@ -7,6 +7,22 @@ export const serverPort = process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"
 export const serverUrl = `http://${serverHost}:${serverPort}`
 export const serverUrl = `http://${serverHost}:${serverPort}`
 export const serverName = `${serverHost}:${serverPort}`
 export const serverName = `${serverHost}:${serverPort}`
 
 
+const localHosts = ["127.0.0.1", "localhost"]
+
+const serverLabels = (() => {
+  const url = new URL(serverUrl)
+  if (!localHosts.includes(url.hostname)) return [serverName]
+  return localHosts.map((host) => `${host}:${url.port}`)
+})()
+
+export const serverNames = [...new Set(serverLabels)]
+
+export const serverUrls = serverNames.map((name) => `http://${name}`)
+
+const escape = (value: string) => value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")
+
+export const serverNamePattern = new RegExp(`(?:${serverNames.map(escape).join("|")})`)
+
 export const modKey = process.platform === "darwin" ? "Meta" : "Control"
 export const modKey = process.platform === "darwin" ? "Meta" : "Control"
 export const terminalToggleKey = "Control+Backquote"
 export const terminalToggleKey = "Control+Backquote"
 
 
@@ -14,6 +30,12 @@ export function createSdk(directory?: string) {
   return createOpencodeClient({ baseUrl: serverUrl, directory, throwOnError: true })
   return createOpencodeClient({ baseUrl: serverUrl, directory, throwOnError: true })
 }
 }
 
 
+export async function resolveDirectory(directory: string) {
+  return createSdk(directory)
+    .path.get()
+    .then((x) => x.data?.directory ?? directory)
+}
+
 export async function getWorktree() {
 export async function getWorktree() {
   const sdk = createSdk()
   const sdk = createSdk()
   const result = await sdk.path.get()
   const result = await sdk.path.get()
@@ -33,3 +55,9 @@ export function dirPath(directory: string) {
 export function sessionPath(directory: string, sessionID?: string) {
 export function sessionPath(directory: string, sessionID?: string) {
   return `${dirPath(directory)}/session${sessionID ? `/${sessionID}` : ""}`
   return `${dirPath(directory)}/session${sessionID ? `/${sessionID}` : ""}`
 }
 }
+
+export function workspacePersistKey(directory: string, key: string) {
+  const head = directory.slice(0, 12) || "workspace"
+  const sum = checksum(directory) ?? "0"
+  return `opencode.workspace.${head}.${sum}.dat:workspace:${key}`
+}

+ 1 - 1
packages/app/package.json

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

+ 5 - 13
packages/app/src/components/prompt-input.tsx

@@ -244,7 +244,6 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
     draggingType: "image" | "@mention" | null
     draggingType: "image" | "@mention" | null
     mode: "normal" | "shell"
     mode: "normal" | "shell"
     applyingHistory: boolean
     applyingHistory: boolean
-    pendingAutoAccept: boolean
   }>({
   }>({
     popover: null,
     popover: null,
     historyIndex: -1,
     historyIndex: -1,
@@ -253,7 +252,6 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
     draggingType: null,
     draggingType: null,
     mode: "normal",
     mode: "normal",
     applyingHistory: false,
     applyingHistory: false,
-    pendingAutoAccept: false,
   })
   })
 
 
   const buttonsSpring = useSpring(() => (store.mode === "normal" ? 1 : 0), { visualDuration: 0.2, bounce: 0 })
   const buttonsSpring = useSpring(() => (store.mode === "normal" ? 1 : 0), { visualDuration: 0.2, bounce: 0 })
@@ -306,12 +304,6 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
     }),
     }),
   )
   )
 
 
-  createEffect(
-    on(sessionKey, () => {
-      setStore("pendingAutoAccept", false)
-    }),
-  )
-
   const historyComments = () => {
   const historyComments = () => {
     const byID = new Map(comments.all().map((item) => [`${item.file}\n${item.id}`, item] as const))
     const byID = new Map(comments.all().map((item) => [`${item.file}\n${item.id}`, item] as const))
     return prompt.context.items().flatMap((item) => {
     return prompt.context.items().flatMap((item) => {
@@ -961,7 +953,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
   const variants = createMemo(() => ["default", ...local.model.variant.list()])
   const variants = createMemo(() => ["default", ...local.model.variant.list()])
   const accepting = createMemo(() => {
   const accepting = createMemo(() => {
     const id = params.id
     const id = params.id
-    if (!id) return store.pendingAutoAccept
+    if (!id) return permission.isAutoAcceptingDirectory(sdk.directory)
     return permission.isAutoAccepting(id, sdk.directory)
     return permission.isAutoAccepting(id, sdk.directory)
   })
   })
 
 
@@ -1211,9 +1203,9 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
               aria-multiline="true"
               aria-multiline="true"
               aria-label={placeholder()}
               aria-label={placeholder()}
               contenteditable="true"
               contenteditable="true"
-              autocapitalize="off"
-              autocorrect="off"
-              spellcheck={false}
+              autocapitalize={store.mode === "normal" ? "sentences" : "off"}
+              autocorrect={store.mode === "normal" ? "on" : "off"}
+              spellcheck={store.mode === "normal"}
               onInput={handleInput}
               onInput={handleInput}
               onPaste={handlePaste}
               onPaste={handlePaste}
               onCompositionStart={() => setComposing(true)}
               onCompositionStart={() => setComposing(true)}
@@ -1336,7 +1328,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
                   variant="ghost"
                   variant="ghost"
                   onClick={() => {
                   onClick={() => {
                     if (!params.id) {
                     if (!params.id) {
-                      setStore("pendingAutoAccept", (value) => !value)
+                      permission.toggleAutoAcceptDirectory(sdk.directory)
                       return
                       return
                     }
                     }
                     permission.toggleAutoAccept(params.id, sdk.directory)
                     permission.toggleAutoAccept(params.id, sdk.directory)

+ 55 - 3
packages/app/src/components/prompt-input/submit.test.ts

@@ -6,10 +6,19 @@ let createPromptSubmit: typeof import("./submit").createPromptSubmit
 const createdClients: string[] = []
 const createdClients: string[] = []
 const createdSessions: string[] = []
 const createdSessions: string[] = []
 const enabledAutoAccept: Array<{ sessionID: string; directory: string }> = []
 const enabledAutoAccept: Array<{ sessionID: string; directory: string }> = []
+const optimistic: Array<{
+  message: {
+    agent: string
+    model: { providerID: string; modelID: string }
+    variant?: string
+  }
+}> = []
 const sentShell: string[] = []
 const sentShell: string[] = []
 const syncedDirectories: string[] = []
 const syncedDirectories: string[] = []
 
 
+let params: { id?: string } = {}
 let selected = "/repo/worktree-a"
 let selected = "/repo/worktree-a"
+let variant: string | undefined
 
 
 const promptValue: Prompt = [{ type: "text", content: "ls", start: 0, end: 2 }]
 const promptValue: Prompt = [{ type: "text", content: "ls", start: 0, end: 2 }]
 
 
@@ -26,6 +35,7 @@ const clientFor = (directory: string) => {
         return { data: undefined }
         return { data: undefined }
       },
       },
       prompt: async () => ({ data: undefined }),
       prompt: async () => ({ data: undefined }),
+      promptAsync: async () => ({ data: undefined }),
       command: async () => ({ data: undefined }),
       command: async () => ({ data: undefined }),
       abort: async () => ({ data: undefined }),
       abort: async () => ({ data: undefined }),
     },
     },
@@ -40,7 +50,7 @@ beforeAll(async () => {
 
 
   mock.module("@solidjs/router", () => ({
   mock.module("@solidjs/router", () => ({
     useNavigate: () => () => undefined,
     useNavigate: () => () => undefined,
-    useParams: () => ({}),
+    useParams: () => params,
   }))
   }))
 
 
   mock.module("@opencode-ai/sdk/v2/client", () => ({
   mock.module("@opencode-ai/sdk/v2/client", () => ({
@@ -62,7 +72,7 @@ beforeAll(async () => {
     useLocal: () => ({
     useLocal: () => ({
       model: {
       model: {
         current: () => ({ id: "model", provider: { id: "provider" } }),
         current: () => ({ id: "model", provider: { id: "provider" } }),
-        variant: { current: () => undefined },
+        variant: { current: () => variant },
       },
       },
       agent: {
       agent: {
         current: () => ({ name: "agent" }),
         current: () => ({ name: "agent" }),
@@ -118,7 +128,11 @@ beforeAll(async () => {
       data: { command: [] },
       data: { command: [] },
       session: {
       session: {
         optimistic: {
         optimistic: {
-          add: () => undefined,
+          add: (value: {
+            message: { agent: string; model: { providerID: string; modelID: string }; variant?: string }
+          }) => {
+            optimistic.push(value)
+          },
           remove: () => undefined,
           remove: () => undefined,
         },
         },
       },
       },
@@ -155,9 +169,12 @@ beforeEach(() => {
   createdClients.length = 0
   createdClients.length = 0
   createdSessions.length = 0
   createdSessions.length = 0
   enabledAutoAccept.length = 0
   enabledAutoAccept.length = 0
+  optimistic.length = 0
+  params = {}
   sentShell.length = 0
   sentShell.length = 0
   syncedDirectories.length = 0
   syncedDirectories.length = 0
   selected = "/repo/worktree-a"
   selected = "/repo/worktree-a"
+  variant = undefined
 })
 })
 
 
 describe("prompt submit worktree selection", () => {
 describe("prompt submit worktree selection", () => {
@@ -219,4 +236,39 @@ describe("prompt submit worktree selection", () => {
 
 
     expect(enabledAutoAccept).toEqual([{ sessionID: "session-1", directory: "/repo/worktree-a" }])
     expect(enabledAutoAccept).toEqual([{ sessionID: "session-1", directory: "/repo/worktree-a" }])
   })
   })
+
+  test("includes the selected variant on optimistic prompts", async () => {
+    params = { id: "session-1" }
+    variant = "high"
+
+    const submit = createPromptSubmit({
+      info: () => ({ id: "session-1" }),
+      imageAttachments: () => [],
+      commentCount: () => 0,
+      autoAccept: () => false,
+      mode: () => "normal",
+      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,
+      onSubmit: () => undefined,
+    })
+
+    const event = { preventDefault: () => undefined } as unknown as Event
+
+    await submit.handleSubmit(event)
+
+    expect(optimistic).toHaveLength(1)
+    expect(optimistic[0]).toMatchObject({
+      message: {
+        agent: "agent",
+        model: { providerID: "provider", modelID: "model" },
+        variant: "high",
+      },
+    })
+  })
 })
 })

+ 1 - 0
packages/app/src/components/prompt-input/submit.ts

@@ -316,6 +316,7 @@ export function createPromptSubmit(input: PromptSubmitInput) {
       time: { created: Date.now() },
       time: { created: Date.now() },
       agent,
       agent,
       model,
       model,
+      variant,
     }
     }
 
 
     const addOptimisticMessage = () =>
     const addOptimisticMessage = () =>

+ 6 - 1
packages/app/src/components/session/session-header.tsx

@@ -303,7 +303,12 @@ export function SessionHeader() {
   })
   })
 
 
   const canOpen = createMemo(() => platform.platform === "desktop" && !!platform.openPath && server.isLocal())
   const canOpen = createMemo(() => platform.platform === "desktop" && !!platform.openPath && server.isLocal())
-  const current = createMemo(() => options().find((o) => o.id === prefs.app) ?? options()[0])
+  const current = createMemo(
+    () =>
+      options().find((o) => o.id === prefs.app) ??
+      options()[0] ??
+      ({ id: "finder", label: fileManager().label, icon: fileManager().icon } as const),
+  )
   const opening = createMemo(() => openRequest.app !== undefined)
   const opening = createMemo(() => openRequest.app !== undefined)
 
 
   const selectApp = (app: OpenApp) => {
   const selectApp = (app: OpenApp) => {

+ 33 - 27
packages/app/src/components/session/session-new-view.tsx

@@ -8,8 +8,7 @@ import { getDirectory, getFilename } from "@opencode-ai/util/path"
 
 
 const MAIN_WORKTREE = "main"
 const MAIN_WORKTREE = "main"
 const CREATE_WORKTREE = "create"
 const CREATE_WORKTREE = "create"
-const ROOT_CLASS =
-  "size-full flex flex-col justify-end items-start gap-4 flex-[1_0_0] self-stretch max-w-200 mx-auto 2xl:max-w-[1000px] px-6 pb-16"
+const ROOT_CLASS = "size-full flex flex-col"
 
 
 interface NewSessionViewProps {
 interface NewSessionViewProps {
   worktree: string
   worktree: string
@@ -50,33 +49,40 @@ export function NewSessionView(props: NewSessionViewProps) {
 
 
   return (
   return (
     <div class={ROOT_CLASS}>
     <div class={ROOT_CLASS}>
-      <div class="text-20-medium text-text-weaker">{language.t("command.session.new")}</div>
-      <div class="flex justify-center items-start gap-3 min-h-5">
-        <Icon name="folder" size="small" class="mt-0.5 shrink-0" />
-        <div class="text-12-medium text-text-weak select-text leading-5">
-          {getDirectory(projectRoot())}
-          <span class="text-text-strong">{getFilename(projectRoot())}</span>
-        </div>
-      </div>
-      <div class="flex justify-center items-start gap-3 min-h-5">
-        <Icon name="branch" size="small" class="mt-0.5 shrink-0" />
-        <div class="text-12-medium text-text-weak select-text leading-5">{label(current())}</div>
-      </div>
-      <Show when={sync.project}>
-        {(project) => (
-          <div class="flex justify-center items-start gap-3 min-h-5">
-            <Icon name="pencil-line" size="small" class="mt-0.5 shrink-0" />
-            <div class="text-12-medium text-text-weak leading-5">
-              {language.t("session.new.lastModified")}&nbsp;
-              <span class="text-text-strong">
-                {DateTime.fromMillis(project().time.updated ?? project().time.created)
-                  .setLocale(language.intl())
-                  .toRelative()}
-              </span>
+      <div class="h-12 shrink-0" aria-hidden />
+      <div class="flex-1 px-6 pb-30 flex items-center justify-center text-center">
+        <div class="w-full max-w-200 flex flex-col items-center text-center gap-4">
+          <div class="text-20-medium text-text-strong">{language.t("session.new.title")}</div>
+          <div class="w-full flex flex-col gap-4 items-center">
+            <div class="flex items-start justify-center gap-3 min-h-5">
+              <div class="text-12-medium text-text-weak select-text leading-5 min-w-0 max-w-160 break-words text-center">
+                {getDirectory(projectRoot())}
+                <span class="text-text-strong">{getFilename(projectRoot())}</span>
+              </div>
             </div>
             </div>
+            <div class="flex items-start justify-center gap-1.5 min-h-5">
+              <Icon name="branch" size="small" class="mt-0.5 shrink-0" />
+              <div class="text-12-medium text-text-weak select-text leading-5 min-w-0 max-w-160 break-words text-center">
+                {label(current())}
+              </div>
+            </div>
+            <Show when={sync.project}>
+              {(project) => (
+                <div class="flex items-start justify-center gap-3 min-h-5">
+                  <div class="text-12-medium text-text-weak leading-5 min-w-0 max-w-160 break-words text-center">
+                    {language.t("session.new.lastModified")}&nbsp;
+                    <span class="text-text-strong">
+                      {DateTime.fromMillis(project().time.updated ?? project().time.created)
+                        .setLocale(language.intl())
+                        .toRelative()}
+                    </span>
+                  </div>
+                </div>
+              )}
+            </Show>
           </div>
           </div>
-        )}
-      </Show>
+        </div>
+      </div>
     </div>
     </div>
   )
   )
 }
 }

+ 2 - 2
packages/app/src/components/titlebar.tsx

@@ -155,7 +155,7 @@ export function Titlebar() {
 
 
   return (
   return (
     <header
     <header
-      class="h-10 shrink-0 bg-background-base relative grid grid-cols-[auto_minmax(0,1fr)_auto] items-center"
+      class="h-10 shrink-0 bg-background-base relative grid grid-cols-[minmax(0,1fr)_auto_minmax(0,1fr)] items-center"
       style={{ "min-height": minHeight() }}
       style={{ "min-height": minHeight() }}
       data-tauri-drag-region
       data-tauri-drag-region
       onMouseDown={drag}
       onMouseDown={drag}
@@ -269,7 +269,7 @@ export function Titlebar() {
       </div>
       </div>
 
 
       <div class="min-w-0 flex items-center justify-center pointer-events-none">
       <div class="min-w-0 flex items-center justify-center pointer-events-none">
-        <div id="opencode-titlebar-center" class="pointer-events-auto w-full min-w-0 flex justify-center lg:w-fit" />
+        <div id="opencode-titlebar-center" class="pointer-events-auto min-w-0 flex justify-center w-fit max-w-full" />
       </div>
       </div>
 
 
       <div
       <div

+ 2 - 0
packages/app/src/context/language.tsx

@@ -146,6 +146,7 @@ const DICT: Record<Locale, Dictionary> = {
 }
 }
 
 
 const localeMatchers: Array<{ locale: Locale; match: (language: string) => boolean }> = [
 const localeMatchers: Array<{ locale: Locale; match: (language: string) => boolean }> = [
+  { locale: "en", match: (language) => language.startsWith("en") },
   { locale: "zht", match: (language) => language.startsWith("zh") && language.includes("hant") },
   { locale: "zht", match: (language) => language.startsWith("zh") && language.includes("hant") },
   { locale: "zh", match: (language) => language.startsWith("zh") },
   { locale: "zh", match: (language) => language.startsWith("zh") },
   { locale: "ko", match: (language) => language.startsWith("ko") },
   { locale: "ko", match: (language) => language.startsWith("ko") },
@@ -217,6 +218,7 @@ export const { use: useLanguage, provider: LanguageProvider } = createSimpleCont
     )
     )
 
 
     const locale = createMemo<Locale>(() => normalizeLocale(store.locale))
     const locale = createMemo<Locale>(() => normalizeLocale(store.locale))
+    console.log("locale", locale())
     const intl = createMemo(() => INTL[locale()])
     const intl = createMemo(() => INTL[locale()])
 
 
     const dict = createMemo<Dictionary>(() => DICT[locale()])
     const dict = createMemo<Dictionary>(() => DICT[locale()])

+ 40 - 1
packages/app/src/context/permission-auto-respond.test.ts

@@ -1,7 +1,7 @@
 import { describe, expect, test } from "bun:test"
 import { describe, expect, test } from "bun:test"
 import type { PermissionRequest, Session } from "@opencode-ai/sdk/v2/client"
 import type { PermissionRequest, Session } from "@opencode-ai/sdk/v2/client"
 import { base64Encode } from "@opencode-ai/util/encode"
 import { base64Encode } from "@opencode-ai/util/encode"
-import { autoRespondsPermission } from "./permission-auto-respond"
+import { autoRespondsPermission, isDirectoryAutoAccepting } from "./permission-auto-respond"
 
 
 const session = (input: { id: string; parentID?: string }) =>
 const session = (input: { id: string; parentID?: string }) =>
   ({
   ({
@@ -60,4 +60,43 @@ describe("autoRespondsPermission", () => {
 
 
     expect(autoRespondsPermission(autoAccept, sessions, permission("child"), directory)).toBe(true)
     expect(autoRespondsPermission(autoAccept, sessions, permission("child"), directory)).toBe(true)
   })
   })
+
+  test("falls back to directory-level auto-accept", () => {
+    const directory = "/tmp/project"
+    const sessions = [session({ id: "root" })]
+    const autoAccept = {
+      [`${base64Encode(directory)}/*`]: true,
+    }
+
+    expect(autoRespondsPermission(autoAccept, sessions, permission("root"), directory)).toBe(true)
+  })
+
+  test("session-level override takes precedence over directory-level", () => {
+    const directory = "/tmp/project"
+    const sessions = [session({ id: "root" })]
+    const autoAccept = {
+      [`${base64Encode(directory)}/*`]: true,
+      [`${base64Encode(directory)}/root`]: false,
+    }
+
+    expect(autoRespondsPermission(autoAccept, sessions, permission("root"), directory)).toBe(false)
+  })
+})
+
+describe("isDirectoryAutoAccepting", () => {
+  test("returns true when directory key is set", () => {
+    const directory = "/tmp/project"
+    const autoAccept = { [`${base64Encode(directory)}/*`]: true }
+    expect(isDirectoryAutoAccepting(autoAccept, directory)).toBe(true)
+  })
+
+  test("returns false when directory key is not set", () => {
+    expect(isDirectoryAutoAccepting({}, "/tmp/project")).toBe(false)
+  })
+
+  test("returns false when directory key is explicitly false", () => {
+    const directory = "/tmp/project"
+    const autoAccept = { [`${base64Encode(directory)}/*`]: false }
+    expect(isDirectoryAutoAccepting(autoAccept, directory)).toBe(false)
+  })
 })
 })

+ 11 - 1
packages/app/src/context/permission-auto-respond.ts

@@ -5,9 +5,19 @@ export function acceptKey(sessionID: string, directory?: string) {
   return `${base64Encode(directory)}/${sessionID}`
   return `${base64Encode(directory)}/${sessionID}`
 }
 }
 
 
+export function directoryAcceptKey(directory: string) {
+  return `${base64Encode(directory)}/*`
+}
+
 function accepted(autoAccept: Record<string, boolean>, sessionID: string, directory?: string) {
 function accepted(autoAccept: Record<string, boolean>, sessionID: string, directory?: string) {
   const key = acceptKey(sessionID, directory)
   const key = acceptKey(sessionID, directory)
-  return autoAccept[key] ?? autoAccept[sessionID]
+  const directoryKey = directory ? directoryAcceptKey(directory) : undefined
+  return autoAccept[key] ?? autoAccept[sessionID] ?? (directoryKey ? autoAccept[directoryKey] : undefined)
+}
+
+export function isDirectoryAutoAccepting(autoAccept: Record<string, boolean>, directory: string) {
+  const key = directoryAcceptKey(directory)
+  return autoAccept[key] ?? false
 }
 }
 
 
 function sessionLineage(session: { id: string; parentID?: string }[], sessionID: string) {
 function sessionLineage(session: { id: string; parentID?: string }[], sessionID: string) {

+ 73 - 2
packages/app/src/context/permission.tsx

@@ -1,4 +1,4 @@
-import { createMemo, onCleanup } from "solid-js"
+import { createEffect, createMemo, onCleanup } from "solid-js"
 import { createStore, produce } from "solid-js/store"
 import { createStore, produce } from "solid-js/store"
 import { createSimpleContext } from "@opencode-ai/ui/context"
 import { createSimpleContext } from "@opencode-ai/ui/context"
 import type { PermissionRequest } from "@opencode-ai/sdk/v2/client"
 import type { PermissionRequest } from "@opencode-ai/sdk/v2/client"
@@ -7,7 +7,12 @@ import { useGlobalSDK } from "@/context/global-sdk"
 import { useGlobalSync } from "./global-sync"
 import { useGlobalSync } from "./global-sync"
 import { useParams } from "@solidjs/router"
 import { useParams } from "@solidjs/router"
 import { decode64 } from "@/utils/base64"
 import { decode64 } from "@/utils/base64"
-import { acceptKey, autoRespondsPermission } from "./permission-auto-respond"
+import {
+  acceptKey,
+  directoryAcceptKey,
+  isDirectoryAutoAccepting,
+  autoRespondsPermission,
+} from "./permission-auto-respond"
 
 
 type PermissionRespondFn = (input: {
 type PermissionRespondFn = (input: {
   sessionID: string
   sessionID: string
@@ -76,6 +81,25 @@ export const { use: usePermission, provider: PermissionProvider } = createSimple
       }),
       }),
     )
     )
 
 
+    // When config has permission: "allow", auto-enable directory-level auto-accept
+    createEffect(() => {
+      if (!ready()) return
+      const directory = decode64(params.dir)
+      if (!directory) return
+      const [childStore] = globalSync.child(directory)
+      const perm = childStore.config.permission
+      if (typeof perm === "string" && perm === "allow") {
+        const key = directoryAcceptKey(directory)
+        if (store.autoAccept[key] === undefined) {
+          setStore(
+            produce((draft) => {
+              draft.autoAccept[key] = true
+            }),
+          )
+        }
+      }
+    })
+
     const MAX_RESPONDED = 1000
     const MAX_RESPONDED = 1000
     const RESPONDED_TTL_MS = 60 * 60 * 1000
     const RESPONDED_TTL_MS = 60 * 60 * 1000
     const responded = new Map<string, number>()
     const responded = new Map<string, number>()
@@ -119,6 +143,10 @@ export const { use: usePermission, provider: PermissionProvider } = createSimple
       return autoRespondsPermission(store.autoAccept, session, { sessionID }, directory)
       return autoRespondsPermission(store.autoAccept, session, { sessionID }, directory)
     }
     }
 
 
+    function isAutoAcceptingDirectory(directory: string) {
+      return isDirectoryAutoAccepting(store.autoAccept, directory)
+    }
+
     function shouldAutoRespond(permission: PermissionRequest, directory?: string) {
     function shouldAutoRespond(permission: PermissionRequest, directory?: string) {
       const session = directory ? globalSync.child(directory, { bootstrap: false })[0].session : []
       const session = directory ? globalSync.child(directory, { bootstrap: false })[0].session : []
       return autoRespondsPermission(store.autoAccept, session, permission, directory)
       return autoRespondsPermission(store.autoAccept, session, permission, directory)
@@ -142,6 +170,36 @@ export const { use: usePermission, provider: PermissionProvider } = createSimple
     })
     })
     onCleanup(unsubscribe)
     onCleanup(unsubscribe)
 
 
+    function enableDirectory(directory: string) {
+      const key = directoryAcceptKey(directory)
+      setStore(
+        produce((draft) => {
+          draft.autoAccept[key] = true
+        }),
+      )
+
+      globalSDK.client.permission
+        .list({ directory })
+        .then((x) => {
+          if (!isAutoAcceptingDirectory(directory)) return
+          for (const perm of x.data ?? []) {
+            if (!perm?.id) continue
+            if (!shouldAutoRespond(perm, directory)) continue
+            respondOnce(perm, directory)
+          }
+        })
+        .catch(() => undefined)
+    }
+
+    function disableDirectory(directory: string) {
+      const key = directoryAcceptKey(directory)
+      setStore(
+        produce((draft) => {
+          draft.autoAccept[key] = false
+        }),
+      )
+    }
+
     function enable(sessionID: string, directory: string) {
     function enable(sessionID: string, directory: string) {
       const key = acceptKey(sessionID, directory)
       const key = acceptKey(sessionID, directory)
       const version = bumpEnableVersion(sessionID, directory)
       const version = bumpEnableVersion(sessionID, directory)
@@ -185,6 +243,7 @@ export const { use: usePermission, provider: PermissionProvider } = createSimple
         return shouldAutoRespond(permission, directory)
         return shouldAutoRespond(permission, directory)
       },
       },
       isAutoAccepting,
       isAutoAccepting,
+      isAutoAcceptingDirectory,
       toggleAutoAccept(sessionID: string, directory: string) {
       toggleAutoAccept(sessionID: string, directory: string) {
         if (isAutoAccepting(sessionID, directory)) {
         if (isAutoAccepting(sessionID, directory)) {
           disable(sessionID, directory)
           disable(sessionID, directory)
@@ -193,6 +252,13 @@ export const { use: usePermission, provider: PermissionProvider } = createSimple
 
 
         enable(sessionID, directory)
         enable(sessionID, directory)
       },
       },
+      toggleAutoAcceptDirectory(directory: string) {
+        if (isAutoAcceptingDirectory(directory)) {
+          disableDirectory(directory)
+          return
+        }
+        enableDirectory(directory)
+      },
       enableAutoAccept(sessionID: string, directory: string) {
       enableAutoAccept(sessionID: string, directory: string) {
         if (isAutoAccepting(sessionID, directory)) return
         if (isAutoAccepting(sessionID, directory)) return
         enable(sessionID, directory)
         enable(sessionID, directory)
@@ -201,6 +267,11 @@ export const { use: usePermission, provider: PermissionProvider } = createSimple
         disable(sessionID, directory)
         disable(sessionID, directory)
       },
       },
       permissionsEnabled,
       permissionsEnabled,
+      isPermissionAllowAll(directory: string) {
+        const [childStore] = globalSync.child(directory)
+        const perm = childStore.config.permission
+        return typeof perm === "string" && perm === "allow"
+      },
     }
     }
   },
   },
 })
 })

+ 2 - 0
packages/app/src/context/sync.tsx

@@ -199,6 +199,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
           parts: Part[]
           parts: Part[]
           agent: string
           agent: string
           model: { providerID: string; modelID: string }
           model: { providerID: string; modelID: string }
+          variant?: string
         }) {
         }) {
           const message: Message = {
           const message: Message = {
             id: input.messageID,
             id: input.messageID,
@@ -207,6 +208,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
             time: { created: Date.now() },
             time: { created: Date.now() },
             agent: input.agent,
             agent: input.agent,
             model: input.model,
             model: input.model,
+            variant: input.variant,
           }
           }
           const [, setStore] = target()
           const [, setStore] = target()
           setOptimisticAdd(setStore as (...args: unknown[]) => void, {
           setOptimisticAdd(setStore as (...args: unknown[]) => void, {

+ 1 - 0
packages/app/src/i18n/ar.ts

@@ -456,6 +456,7 @@ export const dict = {
   "session.todo.title": "المهام",
   "session.todo.title": "المهام",
   "session.todo.collapse": "طي",
   "session.todo.collapse": "طي",
   "session.todo.expand": "توسيع",
   "session.todo.expand": "توسيع",
+  "session.new.title": "ابنِ أي شيء",
   "session.new.worktree.main": "الفرع الرئيسي",
   "session.new.worktree.main": "الفرع الرئيسي",
   "session.new.worktree.mainWithBranch": "الفرع الرئيسي ({{branch}})",
   "session.new.worktree.mainWithBranch": "الفرع الرئيسي ({{branch}})",
   "session.new.worktree.create": "إنشاء شجرة عمل جديدة",
   "session.new.worktree.create": "إنشاء شجرة عمل جديدة",

+ 1 - 0
packages/app/src/i18n/br.ts

@@ -459,6 +459,7 @@ export const dict = {
   "session.todo.title": "Tarefas",
   "session.todo.title": "Tarefas",
   "session.todo.collapse": "Recolher",
   "session.todo.collapse": "Recolher",
   "session.todo.expand": "Expandir",
   "session.todo.expand": "Expandir",
+  "session.new.title": "Crie qualquer coisa",
   "session.new.worktree.main": "Branch principal",
   "session.new.worktree.main": "Branch principal",
   "session.new.worktree.mainWithBranch": "Branch principal ({{branch}})",
   "session.new.worktree.mainWithBranch": "Branch principal ({{branch}})",
   "session.new.worktree.create": "Criar novo worktree",
   "session.new.worktree.create": "Criar novo worktree",

+ 1 - 0
packages/app/src/i18n/bs.ts

@@ -515,6 +515,7 @@ export const dict = {
   "session.todo.collapse": "Sažmi",
   "session.todo.collapse": "Sažmi",
   "session.todo.expand": "Proširi",
   "session.todo.expand": "Proširi",
 
 
+  "session.new.title": "Napravi bilo šta",
   "session.new.worktree.main": "Glavna grana",
   "session.new.worktree.main": "Glavna grana",
   "session.new.worktree.mainWithBranch": "Glavna grana ({{branch}})",
   "session.new.worktree.mainWithBranch": "Glavna grana ({{branch}})",
   "session.new.worktree.create": "Kreiraj novi worktree",
   "session.new.worktree.create": "Kreiraj novi worktree",

+ 1 - 0
packages/app/src/i18n/da.ts

@@ -510,6 +510,7 @@ export const dict = {
   "session.todo.collapse": "Skjul",
   "session.todo.collapse": "Skjul",
   "session.todo.expand": "Udvid",
   "session.todo.expand": "Udvid",
 
 
+  "session.new.title": "Byg hvad som helst",
   "session.new.worktree.main": "Hovedgren",
   "session.new.worktree.main": "Hovedgren",
   "session.new.worktree.mainWithBranch": "Hovedgren ({{branch}})",
   "session.new.worktree.mainWithBranch": "Hovedgren ({{branch}})",
   "session.new.worktree.create": "Opret nyt worktree",
   "session.new.worktree.create": "Opret nyt worktree",

+ 1 - 0
packages/app/src/i18n/de.ts

@@ -467,6 +467,7 @@ export const dict = {
   "session.todo.title": "Aufgaben",
   "session.todo.title": "Aufgaben",
   "session.todo.collapse": "Einklappen",
   "session.todo.collapse": "Einklappen",
   "session.todo.expand": "Ausklappen",
   "session.todo.expand": "Ausklappen",
+  "session.new.title": "Baue, was du willst",
   "session.new.worktree.main": "Haupt-Branch",
   "session.new.worktree.main": "Haupt-Branch",
   "session.new.worktree.mainWithBranch": "Haupt-Branch ({{branch}})",
   "session.new.worktree.mainWithBranch": "Haupt-Branch ({{branch}})",
   "session.new.worktree.create": "Neuen Worktree erstellen",
   "session.new.worktree.create": "Neuen Worktree erstellen",

+ 4 - 1
packages/app/src/i18n/en.ts

@@ -511,11 +511,13 @@ export const dict = {
   "session.review.change.other": "Changes",
   "session.review.change.other": "Changes",
   "session.review.loadingChanges": "Loading changes...",
   "session.review.loadingChanges": "Loading changes...",
   "session.review.empty": "No changes in this session yet",
   "session.review.empty": "No changes in this session yet",
-  "session.review.noVcs": "No git VCS detected, so session changes will not be detected",
+  "session.review.noVcs": "No Git Version Control System detected, changes not displayed",
+  "session.review.noSnapshot": "Snapshot tracking is disabled in config, so session changes are unavailable",
   "session.review.noChanges": "No changes",
   "session.review.noChanges": "No changes",
 
 
   "session.files.selectToOpen": "Select a file to open",
   "session.files.selectToOpen": "Select a file to open",
   "session.files.all": "All files",
   "session.files.all": "All files",
+  "session.files.empty": "No files",
   "session.files.binaryContent": "Binary file (content cannot be displayed)",
   "session.files.binaryContent": "Binary file (content cannot be displayed)",
 
 
   "session.messages.renderEarlier": "Render earlier messages",
   "session.messages.renderEarlier": "Render earlier messages",
@@ -529,6 +531,7 @@ export const dict = {
   "session.todo.collapse": "Collapse",
   "session.todo.collapse": "Collapse",
   "session.todo.expand": "Expand",
   "session.todo.expand": "Expand",
 
 
+  "session.new.title": "Build anything",
   "session.new.worktree.main": "Main branch",
   "session.new.worktree.main": "Main branch",
   "session.new.worktree.mainWithBranch": "Main branch ({{branch}})",
   "session.new.worktree.mainWithBranch": "Main branch ({{branch}})",
   "session.new.worktree.create": "Create new worktree",
   "session.new.worktree.create": "Create new worktree",

+ 1 - 0
packages/app/src/i18n/es.ts

@@ -516,6 +516,7 @@ export const dict = {
   "session.todo.collapse": "Contraer",
   "session.todo.collapse": "Contraer",
   "session.todo.expand": "Expandir",
   "session.todo.expand": "Expandir",
 
 
+  "session.new.title": "Construye lo que quieras",
   "session.new.worktree.main": "Rama principal",
   "session.new.worktree.main": "Rama principal",
   "session.new.worktree.mainWithBranch": "Rama principal ({{branch}})",
   "session.new.worktree.mainWithBranch": "Rama principal ({{branch}})",
   "session.new.worktree.create": "Crear nuevo árbol de trabajo",
   "session.new.worktree.create": "Crear nuevo árbol de trabajo",

+ 1 - 0
packages/app/src/i18n/fr.ts

@@ -463,6 +463,7 @@ export const dict = {
   "session.todo.title": "Tâches",
   "session.todo.title": "Tâches",
   "session.todo.collapse": "Réduire",
   "session.todo.collapse": "Réduire",
   "session.todo.expand": "Développer",
   "session.todo.expand": "Développer",
+  "session.new.title": "Créez ce que vous voulez",
   "session.new.worktree.main": "Branche principale",
   "session.new.worktree.main": "Branche principale",
   "session.new.worktree.mainWithBranch": "Branche principale ({{branch}})",
   "session.new.worktree.mainWithBranch": "Branche principale ({{branch}})",
   "session.new.worktree.create": "Créer un nouvel arbre de travail",
   "session.new.worktree.create": "Créer un nouvel arbre de travail",

+ 1 - 0
packages/app/src/i18n/ja.ts

@@ -457,6 +457,7 @@ export const dict = {
   "session.todo.title": "ToDo",
   "session.todo.title": "ToDo",
   "session.todo.collapse": "折りたたむ",
   "session.todo.collapse": "折りたたむ",
   "session.todo.expand": "展開",
   "session.todo.expand": "展開",
+  "session.new.title": "何でも作る",
   "session.new.worktree.main": "メインブランチ",
   "session.new.worktree.main": "メインブランチ",
   "session.new.worktree.mainWithBranch": "メインブランチ ({{branch}})",
   "session.new.worktree.mainWithBranch": "メインブランチ ({{branch}})",
   "session.new.worktree.create": "新しいワークツリーを作成",
   "session.new.worktree.create": "新しいワークツリーを作成",

+ 1 - 0
packages/app/src/i18n/ko.ts

@@ -459,6 +459,7 @@ export const dict = {
   "session.todo.title": "할 일",
   "session.todo.title": "할 일",
   "session.todo.collapse": "접기",
   "session.todo.collapse": "접기",
   "session.todo.expand": "펼치기",
   "session.todo.expand": "펼치기",
+  "session.new.title": "무엇이든 만들기",
   "session.new.worktree.main": "메인 브랜치",
   "session.new.worktree.main": "메인 브랜치",
   "session.new.worktree.mainWithBranch": "메인 브랜치 ({{branch}})",
   "session.new.worktree.mainWithBranch": "메인 브랜치 ({{branch}})",
   "session.new.worktree.create": "새 작업 트리 생성",
   "session.new.worktree.create": "새 작업 트리 생성",

+ 1 - 0
packages/app/src/i18n/no.ts

@@ -516,6 +516,7 @@ export const dict = {
   "session.todo.collapse": "Skjul",
   "session.todo.collapse": "Skjul",
   "session.todo.expand": "Utvid",
   "session.todo.expand": "Utvid",
 
 
+  "session.new.title": "Bygg hva som helst",
   "session.new.worktree.main": "Hovedgren",
   "session.new.worktree.main": "Hovedgren",
   "session.new.worktree.mainWithBranch": "Hovedgren ({{branch}})",
   "session.new.worktree.mainWithBranch": "Hovedgren ({{branch}})",
   "session.new.worktree.create": "Opprett nytt worktree",
   "session.new.worktree.create": "Opprett nytt worktree",

+ 1 - 0
packages/app/src/i18n/pl.ts

@@ -458,6 +458,7 @@ export const dict = {
   "session.todo.title": "Zadania",
   "session.todo.title": "Zadania",
   "session.todo.collapse": "Zwiń",
   "session.todo.collapse": "Zwiń",
   "session.todo.expand": "Rozwiń",
   "session.todo.expand": "Rozwiń",
+  "session.new.title": "Zbuduj cokolwiek",
   "session.new.worktree.main": "Główna gałąź",
   "session.new.worktree.main": "Główna gałąź",
   "session.new.worktree.mainWithBranch": "Główna gałąź ({{branch}})",
   "session.new.worktree.mainWithBranch": "Główna gałąź ({{branch}})",
   "session.new.worktree.create": "Utwórz nowe drzewo robocze",
   "session.new.worktree.create": "Utwórz nowe drzewo robocze",

+ 1 - 0
packages/app/src/i18n/ru.ts

@@ -514,6 +514,7 @@ export const dict = {
   "session.todo.collapse": "Свернуть",
   "session.todo.collapse": "Свернуть",
   "session.todo.expand": "Развернуть",
   "session.todo.expand": "Развернуть",
 
 
+  "session.new.title": "Создавайте что угодно",
   "session.new.worktree.main": "Основная ветка",
   "session.new.worktree.main": "Основная ветка",
   "session.new.worktree.mainWithBranch": "Основная ветка ({{branch}})",
   "session.new.worktree.mainWithBranch": "Основная ветка ({{branch}})",
   "session.new.worktree.create": "Создать новый worktree",
   "session.new.worktree.create": "Создать новый worktree",

+ 1 - 0
packages/app/src/i18n/th.ts

@@ -511,6 +511,7 @@ export const dict = {
   "session.todo.collapse": "ย่อ",
   "session.todo.collapse": "ย่อ",
   "session.todo.expand": "ขยาย",
   "session.todo.expand": "ขยาย",
 
 
+  "session.new.title": "สร้างอะไรก็ได้",
   "session.new.worktree.main": "สาขาหลัก",
   "session.new.worktree.main": "สาขาหลัก",
   "session.new.worktree.mainWithBranch": "สาขาหลัก ({{branch}})",
   "session.new.worktree.mainWithBranch": "สาขาหลัก ({{branch}})",
   "session.new.worktree.create": "สร้าง worktree ใหม่",
   "session.new.worktree.create": "สร้าง worktree ใหม่",

+ 1 - 0
packages/app/src/i18n/tr.ts

@@ -523,6 +523,7 @@ export const dict = {
   "session.todo.collapse": "Daralt",
   "session.todo.collapse": "Daralt",
   "session.todo.expand": "Genişlet",
   "session.todo.expand": "Genişlet",
 
 
+  "session.new.title": "İstediğini yap",
   "session.new.worktree.main": "Ana dal",
   "session.new.worktree.main": "Ana dal",
   "session.new.worktree.mainWithBranch": "Ana dal ({{branch}})",
   "session.new.worktree.mainWithBranch": "Ana dal ({{branch}})",
   "session.new.worktree.create": "Yeni çalışma ağacı oluştur",
   "session.new.worktree.create": "Yeni çalışma ağacı oluştur",

+ 1 - 0
packages/app/src/i18n/zh.ts

@@ -510,6 +510,7 @@ export const dict = {
   "session.todo.title": "待办事项",
   "session.todo.title": "待办事项",
   "session.todo.collapse": "折叠",
   "session.todo.collapse": "折叠",
   "session.todo.expand": "展开",
   "session.todo.expand": "展开",
+  "session.new.title": "构建任何东西",
   "session.new.worktree.main": "主分支",
   "session.new.worktree.main": "主分支",
   "session.new.worktree.mainWithBranch": "主分支({{branch}})",
   "session.new.worktree.mainWithBranch": "主分支({{branch}})",
   "session.new.worktree.create": "创建新的 worktree",
   "session.new.worktree.create": "创建新的 worktree",

+ 1 - 0
packages/app/src/i18n/zht.ts

@@ -507,6 +507,7 @@ export const dict = {
   "session.todo.collapse": "折疊",
   "session.todo.collapse": "折疊",
   "session.todo.expand": "展開",
   "session.todo.expand": "展開",
 
 
+  "session.new.title": "建構任何東西",
   "session.new.worktree.main": "主分支",
   "session.new.worktree.main": "主分支",
   "session.new.worktree.mainWithBranch": "主分支 ({{branch}})",
   "session.new.worktree.mainWithBranch": "主分支 ({{branch}})",
   "session.new.worktree.create": "建立新的 worktree",
   "session.new.worktree.create": "建立新的 worktree",

+ 28 - 0
packages/app/src/index.css

@@ -1 +1,29 @@
 @import "@opencode-ai/ui/styles/tailwind";
 @import "@opencode-ai/ui/styles/tailwind";
+
+@layer components {
+  [data-component="getting-started"] {
+    container-type: inline-size;
+    container-name: getting-started;
+  }
+
+  [data-component="getting-started-actions"] {
+    display: flex;
+    flex-direction: column;
+    gap: 0.75rem; /* gap-3 */
+  }
+
+  [data-component="getting-started-actions"] > [data-component="button"] {
+    width: 100%;
+  }
+
+  @container getting-started (min-width: 17rem) {
+    [data-component="getting-started-actions"] {
+      flex-direction: row;
+      align-items: center;
+    }
+
+    [data-component="getting-started-actions"] > [data-component="button"] {
+      width: auto;
+    }
+  }
+}

+ 58 - 25
packages/app/src/pages/directory-layout.tsx

@@ -1,26 +1,27 @@
-import { createEffect, createMemo, Show, type ParentProps } from "solid-js"
+import { batch, createEffect, createMemo, Show, type ParentProps } from "solid-js"
 import { createStore } from "solid-js/store"
 import { createStore } from "solid-js/store"
-import { useNavigate, useParams } from "@solidjs/router"
+import { useLocation, useNavigate, useParams } from "@solidjs/router"
 import { SDKProvider } from "@/context/sdk"
 import { SDKProvider } from "@/context/sdk"
 import { SyncProvider, useSync } from "@/context/sync"
 import { SyncProvider, useSync } from "@/context/sync"
 import { LocalProvider } from "@/context/local"
 import { LocalProvider } from "@/context/local"
+import { useGlobalSDK } from "@/context/global-sdk"
 
 
 import { DataProvider } from "@opencode-ai/ui/context"
 import { DataProvider } from "@opencode-ai/ui/context"
+import { base64Encode } from "@opencode-ai/util/encode"
 import { decode64 } from "@/utils/base64"
 import { decode64 } from "@/utils/base64"
 import { showToast } from "@opencode-ai/ui/toast"
 import { showToast } from "@opencode-ai/ui/toast"
 import { useLanguage } from "@/context/language"
 import { useLanguage } from "@/context/language"
-
 function DirectoryDataProvider(props: ParentProps<{ directory: string }>) {
 function DirectoryDataProvider(props: ParentProps<{ directory: string }>) {
-  const params = useParams()
   const navigate = useNavigate()
   const navigate = useNavigate()
   const sync = useSync()
   const sync = useSync()
+  const slug = createMemo(() => base64Encode(props.directory))
 
 
   return (
   return (
     <DataProvider
     <DataProvider
       data={sync.data}
       data={sync.data}
       directory={props.directory}
       directory={props.directory}
-      onNavigateToSession={(sessionID: string) => navigate(`/${params.dir}/session/${sessionID}`)}
-      onSessionHref={(sessionID: string) => `/${params.dir}/session/${sessionID}`}
+      onNavigateToSession={(sessionID: string) => navigate(`/${slug()}/session/${sessionID}`)}
+      onSessionHref={(sessionID: string) => `/${slug()}/session/${sessionID}`}
     >
     >
       <LocalProvider>{props.children}</LocalProvider>
       <LocalProvider>{props.children}</LocalProvider>
     </DataProvider>
     </DataProvider>
@@ -30,31 +31,63 @@ function DirectoryDataProvider(props: ParentProps<{ directory: string }>) {
 export default function Layout(props: ParentProps) {
 export default function Layout(props: ParentProps) {
   const params = useParams()
   const params = useParams()
   const navigate = useNavigate()
   const navigate = useNavigate()
+  const location = useLocation()
   const language = useLanguage()
   const language = useLanguage()
-  const [store, setStore] = createStore({ invalid: "" })
-  const directory = createMemo(() => {
-    return decode64(params.dir) ?? ""
-  })
+  const globalSDK = useGlobalSDK()
+  const directory = createMemo(() => decode64(params.dir) ?? "")
+  const [state, setState] = createStore({ invalid: "", resolved: "" })
 
 
   createEffect(() => {
   createEffect(() => {
     if (!params.dir) return
     if (!params.dir) return
-    if (directory()) return
-    if (store.invalid === params.dir) return
-    setStore("invalid", params.dir)
-    showToast({
-      variant: "error",
-      title: language.t("common.requestFailed"),
-      description: language.t("directory.error.invalidUrl"),
-    })
-    navigate("/", { replace: true })
+    const raw = directory()
+    if (!raw) {
+      if (state.invalid === params.dir) return
+      setState("invalid", params.dir)
+      showToast({
+        variant: "error",
+        title: language.t("common.requestFailed"),
+        description: language.t("directory.error.invalidUrl"),
+      })
+      navigate("/", { replace: true })
+      return
+    }
+
+    const current = params.dir
+    globalSDK
+      .createClient({
+        directory: raw,
+        throwOnError: true,
+      })
+      .path.get()
+      .then((x) => {
+        if (params.dir !== current) return
+        const next = x.data?.directory ?? raw
+        batch(() => {
+          setState("invalid", "")
+          setState("resolved", next)
+        })
+        if (next === raw) return
+        const path = location.pathname.slice(current.length + 1)
+        navigate(`/${base64Encode(next)}${path}${location.search}${location.hash}`, { replace: true })
+      })
+      .catch(() => {
+        if (params.dir !== current) return
+        batch(() => {
+          setState("invalid", "")
+          setState("resolved", raw)
+        })
+      })
   })
   })
+
   return (
   return (
-    <Show when={directory()}>
-      <SDKProvider directory={directory}>
-        <SyncProvider>
-          <DirectoryDataProvider directory={directory()}>{props.children}</DirectoryDataProvider>
-        </SyncProvider>
-      </SDKProvider>
+    <Show when={state.resolved}>
+      {(resolved) => (
+        <SDKProvider directory={resolved}>
+          <SyncProvider>
+            <DirectoryDataProvider directory={resolved()}>{props.children}</DirectoryDataProvider>
+          </SyncProvider>
+        </SDKProvider>
+      )}
     </Show>
     </Show>
   )
   )
 }
 }

+ 203 - 77
packages/app/src/pages/layout.tsx

@@ -10,9 +10,8 @@ import {
   ParentProps,
   ParentProps,
   Show,
   Show,
   untrack,
   untrack,
-  type JSX,
 } from "solid-js"
 } from "solid-js"
-import { A, useNavigate, useParams } from "@solidjs/router"
+import { useNavigate, useParams } from "@solidjs/router"
 import { useLayout, LocalProject } from "@/context/layout"
 import { useLayout, LocalProject } from "@/context/layout"
 import { useGlobalSync } from "@/context/global-sync"
 import { useGlobalSync } from "@/context/global-sync"
 import { Persist, persisted } from "@/utils/persist"
 import { Persist, persisted } from "@/utils/persist"
@@ -20,7 +19,6 @@ import { base64Encode } from "@opencode-ai/util/encode"
 import { decode64 } from "@/utils/base64"
 import { decode64 } from "@/utils/base64"
 import { ResizeHandle } from "@opencode-ai/ui/resize-handle"
 import { ResizeHandle } from "@opencode-ai/ui/resize-handle"
 import { Button } from "@opencode-ai/ui/button"
 import { Button } from "@opencode-ai/ui/button"
-import { Icon } from "@opencode-ai/ui/icon"
 import { IconButton } from "@opencode-ai/ui/icon-button"
 import { IconButton } from "@opencode-ai/ui/icon-button"
 import { Tooltip } from "@opencode-ai/ui/tooltip"
 import { Tooltip } from "@opencode-ai/ui/tooltip"
 import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu"
 import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu"
@@ -59,7 +57,6 @@ import { Titlebar } from "@/components/titlebar"
 import { useServer } from "@/context/server"
 import { useServer } from "@/context/server"
 import { useLanguage, type Locale } from "@/context/language"
 import { useLanguage, type Locale } from "@/context/language"
 import {
 import {
-  childMapByParent,
   displayName,
   displayName,
   effectiveWorkspaceOrder,
   effectiveWorkspaceOrder,
   errorMessage,
   errorMessage,
@@ -96,6 +93,7 @@ export default function Layout(props: ParentProps) {
       workspaceName: {} as Record<string, string>,
       workspaceName: {} as Record<string, string>,
       workspaceBranchName: {} as Record<string, Record<string, string>>,
       workspaceBranchName: {} as Record<string, Record<string, string>>,
       workspaceExpanded: {} as Record<string, boolean>,
       workspaceExpanded: {} as Record<string, boolean>,
+      gettingStartedDismissed: false,
     }),
     }),
   )
   )
 
 
@@ -157,6 +155,8 @@ export default function Layout(props: ParentProps) {
   const isBusy = (directory: string) => !!state.busyWorkspaces[workspaceKey(directory)]
   const isBusy = (directory: string) => !!state.busyWorkspaces[workspaceKey(directory)]
   const navLeave = { current: undefined as number | undefined }
   const navLeave = { current: undefined as number | undefined }
   const [sortNow, setSortNow] = createSignal(Date.now())
   const [sortNow, setSortNow] = createSignal(Date.now())
+  const [sizing, setSizing] = createSignal(false)
+  let sizet: number | undefined
   let sortNowInterval: ReturnType<typeof setInterval> | undefined
   let sortNowInterval: ReturnType<typeof setInterval> | undefined
   const sortNowTimeout = setTimeout(
   const sortNowTimeout = setTimeout(
     () => {
     () => {
@@ -169,7 +169,7 @@ export default function Layout(props: ParentProps) {
   const aim = createAim({
   const aim = createAim({
     enabled: () => !layout.sidebar.opened(),
     enabled: () => !layout.sidebar.opened(),
     active: () => state.hoverProject,
     active: () => state.hoverProject,
-    el: () => state.nav,
+    el: () => state.nav?.querySelector<HTMLElement>("[data-component='sidebar-rail']") ?? state.nav,
     onActivate: (directory) => {
     onActivate: (directory) => {
       globalSync.child(directory)
       globalSync.child(directory)
       setState("hoverProject", directory)
       setState("hoverProject", directory)
@@ -181,9 +181,23 @@ export default function Layout(props: ParentProps) {
     if (navLeave.current !== undefined) clearTimeout(navLeave.current)
     if (navLeave.current !== undefined) clearTimeout(navLeave.current)
     clearTimeout(sortNowTimeout)
     clearTimeout(sortNowTimeout)
     if (sortNowInterval) clearInterval(sortNowInterval)
     if (sortNowInterval) clearInterval(sortNowInterval)
+    if (sizet !== undefined) clearTimeout(sizet)
+    if (peekt !== undefined) clearTimeout(peekt)
     aim.reset()
     aim.reset()
   })
   })
 
 
+  onMount(() => {
+    const stop = () => setSizing(false)
+    window.addEventListener("pointerup", stop)
+    window.addEventListener("pointercancel", stop)
+    window.addEventListener("blur", stop)
+    onCleanup(() => {
+      window.removeEventListener("pointerup", stop)
+      window.removeEventListener("pointercancel", stop)
+      window.removeEventListener("blur", stop)
+    })
+  })
+
   const sidebarHovering = createMemo(() => !layout.sidebar.opened() && state.hoverProject !== undefined)
   const sidebarHovering = createMemo(() => !layout.sidebar.opened() && state.hoverProject !== undefined)
   const sidebarExpanded = createMemo(() => layout.sidebar.opened() || sidebarHovering())
   const sidebarExpanded = createMemo(() => layout.sidebar.opened() || sidebarHovering())
   const setHoverProject = (value: string | undefined) => {
   const setHoverProject = (value: string | undefined) => {
@@ -194,12 +208,54 @@ export default function Layout(props: ParentProps) {
   const clearHoverProjectSoon = () => queueMicrotask(() => setHoverProject(undefined))
   const clearHoverProjectSoon = () => queueMicrotask(() => setHoverProject(undefined))
   const setHoverSession = (id: string | undefined) => setState("hoverSession", id)
   const setHoverSession = (id: string | undefined) => setState("hoverSession", id)
 
 
+  const disarm = () => {
+    if (navLeave.current === undefined) return
+    clearTimeout(navLeave.current)
+    navLeave.current = undefined
+  }
+
+  const arm = () => {
+    if (layout.sidebar.opened()) return
+    if (state.hoverProject === undefined) return
+    disarm()
+    navLeave.current = window.setTimeout(() => {
+      navLeave.current = undefined
+      setHoverProject(undefined)
+      setState("hoverSession", undefined)
+    }, 300)
+  }
+
+  const [peek, setPeek] = createSignal<LocalProject | undefined>(undefined)
+  const [peeked, setPeeked] = createSignal(false)
+  let peekt: number | undefined
+
   const hoverProjectData = createMemo(() => {
   const hoverProjectData = createMemo(() => {
     const id = state.hoverProject
     const id = state.hoverProject
     if (!id) return
     if (!id) return
     return layout.projects.list().find((project) => project.worktree === id)
     return layout.projects.list().find((project) => project.worktree === id)
   })
   })
 
 
+  createEffect(() => {
+    const p = hoverProjectData()
+    if (p) {
+      if (peekt !== undefined) {
+        clearTimeout(peekt)
+        peekt = undefined
+      }
+      setPeek(p)
+      setPeeked(true)
+      return
+    }
+
+    setPeeked(false)
+    if (peek() === undefined) return
+    if (peekt !== undefined) clearTimeout(peekt)
+    peekt = window.setTimeout(() => {
+      peekt = undefined
+      setPeek(undefined)
+    }, 180)
+  })
+
   createEffect(() => {
   createEffect(() => {
     if (!layout.sidebar.opened()) return
     if (!layout.sidebar.opened()) return
     setHoverProject(undefined)
     setHoverProject(undefined)
@@ -1125,6 +1181,12 @@ export default function Layout(props: ParentProps) {
     }
     }
     const openSession = async (target: { directory: string; id: string }) => {
     const openSession = async (target: { directory: string; id: string }) => {
       if (!canOpen(target.directory)) return false
       if (!canOpen(target.directory)) return false
+      const [data] = globalSync.child(target.directory, { bootstrap: false })
+      if (data.session.some((item) => item.id === target.id)) {
+        setStore("lastProjectSession", root, { directory: target.directory, id: target.id, at: Date.now() })
+        navigateWithSidebarReset(`/${base64Encode(target.directory)}/session/${target.id}`)
+        return true
+      }
       const resolved = await globalSDK.client.session
       const resolved = await globalSDK.client.session
         .get({ sessionID: target.id })
         .get({ sessionID: target.id })
         .then((x) => x.data)
         .then((x) => x.data)
@@ -1815,7 +1877,8 @@ export default function Layout(props: ParentProps) {
     setHoverSession,
     setHoverSession,
   }
   }
 
 
-  const SidebarPanel = (panelProps: { project: LocalProject | undefined; mobile?: boolean }) => {
+  const SidebarPanel = (panelProps: { project: LocalProject | undefined; mobile?: boolean; merged?: boolean }) => {
+    const merged = createMemo(() => panelProps.mobile || (panelProps.merged ?? layout.sidebar.opened()))
     const projectName = createMemo(() => {
     const projectName = createMemo(() => {
       const project = panelProps.project
       const project = panelProps.project
       if (!project) return ""
       if (!project) return ""
@@ -1841,12 +1904,19 @@ export default function Layout(props: ParentProps) {
     return (
     return (
       <div
       <div
         classList={{
         classList={{
-          "flex flex-col min-h-0 bg-background-stronger border border-b-0 border-border-weak-base rounded-tl-[12px]": true,
+          "flex flex-col min-h-0 min-w-0 rounded-tl-[12px] px-2": true,
+          "border border-b-0 border-border-weak-base": !merged(),
+          "border-l border-t border-border-weaker-base": merged(),
+          "bg-background-base": merged(),
+          "bg-background-stronger": !merged(),
           "flex-1 min-w-0": panelProps.mobile,
           "flex-1 min-w-0": panelProps.mobile,
+          "max-w-full overflow-hidden": panelProps.mobile,
+        }}
+        style={{
+          width: panelProps.mobile ? undefined : `${Math.max(Math.max(layout.sidebar.width(), 244) - 64, 0)}px`,
         }}
         }}
-        style={{ width: panelProps.mobile ? undefined : `${Math.max(layout.sidebar.width() - 64, 0)}px` }}
       >
       >
-        <Show when={panelProps.project} keyed>
+        <Show when={panelProps.project}>
           {(p) => (
           {(p) => (
             <>
             <>
               <div class="shrink-0 px-2 py-1">
               <div class="shrink-0 px-2 py-1">
@@ -1855,7 +1925,7 @@ export default function Layout(props: ParentProps) {
                     <InlineEditor
                     <InlineEditor
                       id={`project:${projectId()}`}
                       id={`project:${projectId()}`}
                       value={projectName}
                       value={projectName}
-                      onSave={(next) => renameProject(p, next)}
+                      onSave={(next) => renameProject(p(), next)}
                       class="text-14-medium text-text-strong truncate"
                       class="text-14-medium text-text-strong truncate"
                       displayClass="text-14-medium text-text-strong truncate"
                       displayClass="text-14-medium text-text-strong truncate"
                       stopPropagation
                       stopPropagation
@@ -1864,7 +1934,7 @@ export default function Layout(props: ParentProps) {
                     <Tooltip
                     <Tooltip
                       placement="bottom"
                       placement="bottom"
                       gutter={2}
                       gutter={2}
-                      value={p.worktree}
+                      value={p().worktree}
                       class="shrink-0"
                       class="shrink-0"
                       contentStyle={{
                       contentStyle={{
                         "max-width": "640px",
                         "max-width": "640px",
@@ -1872,7 +1942,7 @@ export default function Layout(props: ParentProps) {
                       }}
                       }}
                     >
                     >
                       <span class="text-12-regular text-text-base truncate select-text">
                       <span class="text-12-regular text-text-base truncate select-text">
-                        {p.worktree.replace(homedir(), "~")}
+                        {p().worktree.replace(homedir(), "~")}
                       </span>
                       </span>
                     </Tooltip>
                     </Tooltip>
                   </div>
                   </div>
@@ -1883,33 +1953,33 @@ export default function Layout(props: ParentProps) {
                       icon="dot-grid"
                       icon="dot-grid"
                       variant="ghost"
                       variant="ghost"
                       data-action="project-menu"
                       data-action="project-menu"
-                      data-project={base64Encode(p.worktree)}
+                      data-project={base64Encode(p().worktree)}
                       class="shrink-0 size-6 rounded-md data-[expanded]:bg-surface-base-active"
                       class="shrink-0 size-6 rounded-md data-[expanded]:bg-surface-base-active"
                       classList={{
                       classList={{
                         "opacity-0 group-hover/project:opacity-100 data-[expanded]:opacity-100": !panelProps.mobile,
                         "opacity-0 group-hover/project:opacity-100 data-[expanded]:opacity-100": !panelProps.mobile,
                       }}
                       }}
                       aria-label={language.t("common.moreOptions")}
                       aria-label={language.t("common.moreOptions")}
                     />
                     />
-                    <DropdownMenu.Portal mount={!panelProps.mobile ? state.nav : undefined}>
+                    <DropdownMenu.Portal>
                       <DropdownMenu.Content class="mt-1">
                       <DropdownMenu.Content class="mt-1">
-                        <DropdownMenu.Item onSelect={() => showEditProjectDialog(p)}>
+                        <DropdownMenu.Item onSelect={() => showEditProjectDialog(p())}>
                           <DropdownMenu.ItemLabel>{language.t("common.edit")}</DropdownMenu.ItemLabel>
                           <DropdownMenu.ItemLabel>{language.t("common.edit")}</DropdownMenu.ItemLabel>
                         </DropdownMenu.Item>
                         </DropdownMenu.Item>
                         <DropdownMenu.Item
                         <DropdownMenu.Item
                           data-action="project-workspaces-toggle"
                           data-action="project-workspaces-toggle"
-                          data-project={base64Encode(p.worktree)}
-                          disabled={p.vcs !== "git" && !layout.sidebar.workspaces(p.worktree)()}
-                          onSelect={() => toggleProjectWorkspaces(p)}
+                          data-project={base64Encode(p().worktree)}
+                          disabled={p().vcs !== "git" && !layout.sidebar.workspaces(p().worktree)()}
+                          onSelect={() => toggleProjectWorkspaces(p())}
                         >
                         >
                           <DropdownMenu.ItemLabel>
                           <DropdownMenu.ItemLabel>
-                            {layout.sidebar.workspaces(p.worktree)()
+                            {layout.sidebar.workspaces(p().worktree)()
                               ? language.t("sidebar.workspaces.disable")
                               ? language.t("sidebar.workspaces.disable")
                               : language.t("sidebar.workspaces.enable")}
                               : language.t("sidebar.workspaces.enable")}
                           </DropdownMenu.ItemLabel>
                           </DropdownMenu.ItemLabel>
                         </DropdownMenu.Item>
                         </DropdownMenu.Item>
                         <DropdownMenu.Item
                         <DropdownMenu.Item
                           data-action="project-clear-notifications"
                           data-action="project-clear-notifications"
-                          data-project={base64Encode(p.worktree)}
+                          data-project={base64Encode(p().worktree)}
                           disabled={unseenCount() === 0}
                           disabled={unseenCount() === 0}
                           onSelect={clearNotifications}
                           onSelect={clearNotifications}
                         >
                         >
@@ -1920,8 +1990,8 @@ export default function Layout(props: ParentProps) {
                         <DropdownMenu.Separator />
                         <DropdownMenu.Separator />
                         <DropdownMenu.Item
                         <DropdownMenu.Item
                           data-action="project-close-menu"
                           data-action="project-close-menu"
-                          data-project={base64Encode(p.worktree)}
-                          onSelect={() => closeProject(p.worktree)}
+                          data-project={base64Encode(p().worktree)}
+                          onSelect={() => closeProject(p().worktree)}
                         >
                         >
                           <DropdownMenu.ItemLabel>{language.t("common.close")}</DropdownMenu.ItemLabel>
                           <DropdownMenu.ItemLabel>{language.t("common.close")}</DropdownMenu.ItemLabel>
                         </DropdownMenu.Item>
                         </DropdownMenu.Item>
@@ -1941,7 +2011,7 @@ export default function Layout(props: ParentProps) {
                           size="large"
                           size="large"
                           icon="plus-small"
                           icon="plus-small"
                           class="w-full"
                           class="w-full"
-                          onClick={() => navigateWithSidebarReset(`/${base64Encode(p.worktree)}/session`)}
+                          onClick={() => navigateWithSidebarReset(`/${base64Encode(p().worktree)}/session`)}
                         >
                         >
                           {language.t("command.session.new")}
                           {language.t("command.session.new")}
                         </Button>
                         </Button>
@@ -1949,7 +2019,7 @@ export default function Layout(props: ParentProps) {
                       <div class="flex-1 min-h-0">
                       <div class="flex-1 min-h-0">
                         <LocalWorkspace
                         <LocalWorkspace
                           ctx={workspaceSidebarCtx}
                           ctx={workspaceSidebarCtx}
-                          project={p}
+                          project={p()}
                           sortNow={sortNow}
                           sortNow={sortNow}
                           mobile={panelProps.mobile}
                           mobile={panelProps.mobile}
                         />
                         />
@@ -1959,7 +2029,7 @@ export default function Layout(props: ParentProps) {
                 >
                 >
                   <>
                   <>
                     <div class="shrink-0 py-4 px-3">
                     <div class="shrink-0 py-4 px-3">
-                      <Button size="large" icon="plus-small" class="w-full" onClick={() => createWorkspace(p)}>
+                      <Button size="large" icon="plus-small" class="w-full" onClick={() => createWorkspace(p())}>
                         {language.t("workspace.new")}
                         {language.t("workspace.new")}
                       </Button>
                       </Button>
                     </div>
                     </div>
@@ -1984,7 +2054,7 @@ export default function Layout(props: ParentProps) {
                                 <SortableWorkspace
                                 <SortableWorkspace
                                   ctx={workspaceSidebarCtx}
                                   ctx={workspaceSidebarCtx}
                                   directory={directory}
                                   directory={directory}
-                                  project={p}
+                                  project={p()}
                                   sortNow={sortNow}
                                   sortNow={sortNow}
                                   mobile={panelProps.mobile}
                                   mobile={panelProps.mobile}
                                 />
                                 />
@@ -2009,25 +2079,31 @@ export default function Layout(props: ParentProps) {
         </Show>
         </Show>
 
 
         <div
         <div
-          class="shrink-0 px-2 py-3 border-t border-border-weak-base"
+          class="shrink-0 px-3 py-3"
           classList={{
           classList={{
-            hidden: !(providers.all().length > 0 && providers.paid().length === 0),
+            hidden: store.gettingStartedDismissed || !(providers.all().length > 0 && providers.paid().length === 0),
           }}
           }}
         >
         >
-          <div class="rounded-md bg-background-base shadow-xs-border-base">
-            <div class="p-3 flex flex-col gap-2">
-              <div class="text-12-medium text-text-strong">{language.t("sidebar.gettingStarted.title")}</div>
-              <div class="text-text-base">{language.t("sidebar.gettingStarted.line1")}</div>
-              <div class="text-text-base">{language.t("sidebar.gettingStarted.line2")}</div>
+          <div class="rounded-xl bg-background-base shadow-xs-border-base" data-component="getting-started">
+            <div class="p-3 flex flex-col gap-6">
+              <div class="flex flex-col gap-2">
+                <div class="text-14-medium text-text-strong">{language.t("sidebar.gettingStarted.title")}</div>
+                <div class="text-14-regular text-text-base" style={{ "line-height": "var(--line-height-normal)" }}>
+                  {language.t("sidebar.gettingStarted.line1")}
+                </div>
+                <div class="text-14-regular text-text-base" style={{ "line-height": "var(--line-height-normal)" }}>
+                  {language.t("sidebar.gettingStarted.line2")}
+                </div>
+              </div>
+              <div data-component="getting-started-actions">
+                <Button size="large" icon="plus-small" onClick={connectProvider}>
+                  {language.t("command.provider.connect")}
+                </Button>
+                <Button size="large" variant="ghost" onClick={() => setStore("gettingStartedDismissed", true)}>
+                  Not yet
+                </Button>
+              </div>
             </div>
             </div>
-            <Button
-              class="flex w-full text-left justify-start text-12-medium text-text-strong stroke-[1.5px] rounded-md rounded-t-none shadow-none border-t border-border-weak-base px-3"
-              size="large"
-              icon="plus"
-              onClick={connectProvider}
-            >
-              {language.t("command.provider.connect")}
-            </Button>
           </div>
           </div>
         </div>
         </div>
       </div>
       </div>
@@ -2037,33 +2113,27 @@ export default function Layout(props: ParentProps) {
   return (
   return (
     <div class="relative bg-background-base flex-1 min-h-0 flex flex-col select-none [&_input]:select-text [&_textarea]:select-text [&_[contenteditable]]:select-text">
     <div class="relative bg-background-base flex-1 min-h-0 flex flex-col select-none [&_input]:select-text [&_textarea]:select-text [&_[contenteditable]]:select-text">
       <Titlebar />
       <Titlebar />
-      <div class="flex-1 min-h-0 flex">
+      <div class="flex-1 min-h-0 relative overflow-x-hidden">
         <nav
         <nav
           aria-label={language.t("sidebar.nav.projectsAndSessions")}
           aria-label={language.t("sidebar.nav.projectsAndSessions")}
           data-component="sidebar-nav-desktop"
           data-component="sidebar-nav-desktop"
           classList={{
           classList={{
             "hidden xl:block": true,
             "hidden xl:block": true,
-            "relative shrink-0": true,
+            "absolute inset-y-0 left-0": true,
+            "z-10": true,
           }}
           }}
-          style={{ width: layout.sidebar.opened() ? `${Math.max(layout.sidebar.width(), 244)}px` : "64px" }}
+          style={{ width: `${Math.max(layout.sidebar.width(), 244)}px` }}
           ref={(el) => {
           ref={(el) => {
             setState("nav", el)
             setState("nav", el)
           }}
           }}
           onMouseEnter={() => {
           onMouseEnter={() => {
-            if (navLeave.current === undefined) return
-            clearTimeout(navLeave.current)
-            navLeave.current = undefined
+            disarm()
           }}
           }}
           onMouseLeave={() => {
           onMouseLeave={() => {
             aim.reset()
             aim.reset()
             if (!sidebarHovering()) return
             if (!sidebarHovering()) return
 
 
-            if (navLeave.current !== undefined) clearTimeout(navLeave.current)
-            navLeave.current = window.setTimeout(() => {
-              navLeave.current = undefined
-              setHoverProject(undefined)
-              setState("hoverSession", undefined)
-            }, 300)
+            arm()
           }}
           }}
         >
         >
           <div class="@container w-full h-full contain-strict">
           <div class="@container w-full h-full contain-strict">
@@ -2090,30 +2160,36 @@ export default function Layout(props: ParentProps) {
               onOpenHelp={() => platform.openLink("https://opencode.ai/desktop-feedback")}
               onOpenHelp={() => platform.openLink("https://opencode.ai/desktop-feedback")}
               renderPanel={() => (
               renderPanel={() => (
                 <Show when={currentProject()} keyed>
                 <Show when={currentProject()} keyed>
-                  {(project) => <SidebarPanel project={project} />}
+                  {(project) => <SidebarPanel project={project} merged />}
                 </Show>
                 </Show>
               )}
               )}
             />
             />
           </div>
           </div>
-          <Show when={!layout.sidebar.opened() ? hoverProjectData()?.worktree : undefined} keyed>
-            {(worktree) => (
-              <div class="absolute inset-y-0 left-16 z-50 flex" onMouseEnter={aim.reset}>
-                <SidebarPanel project={hoverProjectData()} />
-              </div>
-            )}
-          </Show>
           <Show when={layout.sidebar.opened()}>
           <Show when={layout.sidebar.opened()}>
-            <ResizeHandle
-              direction="horizontal"
-              size={layout.sidebar.width()}
-              min={244}
-              max={typeof window === "undefined" ? 1000 : window.innerWidth * 0.3 + 64}
-              collapseThreshold={244}
-              onResize={layout.sidebar.resize}
-              onCollapse={layout.sidebar.close}
-            />
+            <div onPointerDown={() => setSizing(true)}>
+              <ResizeHandle
+                direction="horizontal"
+                size={layout.sidebar.width()}
+                min={244}
+                max={typeof window === "undefined" ? 1000 : window.innerWidth * 0.3 + 64}
+                collapseThreshold={244}
+                onResize={(w) => {
+                  setSizing(true)
+                  if (sizet !== undefined) clearTimeout(sizet)
+                  sizet = window.setTimeout(() => setSizing(false), 120)
+                  layout.sidebar.resize(w)
+                }}
+                onCollapse={layout.sidebar.close}
+              />
+            </div>
           </Show>
           </Show>
         </nav>
         </nav>
+
+        <div
+          class="hidden xl:block pointer-events-none absolute top-0 right-0 z-0 border-t border-border-weaker-base"
+          style={{ left: "calc(4rem + 12px)" }}
+        />
+
         <div class="xl:hidden">
         <div class="xl:hidden">
           <div
           <div
             classList={{
             classList={{
@@ -2129,7 +2205,7 @@ export default function Layout(props: ParentProps) {
             aria-label={language.t("sidebar.nav.projectsAndSessions")}
             aria-label={language.t("sidebar.nav.projectsAndSessions")}
             data-component="sidebar-nav-mobile"
             data-component="sidebar-nav-mobile"
             classList={{
             classList={{
-              "@container fixed top-10 bottom-0 left-0 z-50 w-72 bg-background-base transition-transform duration-200 ease-out": true,
+              "@container fixed top-10 bottom-0 left-0 z-50 w-full max-w-[400px] overflow-hidden border-r border-border-weaker-base bg-background-base transition-transform duration-200 ease-out": true,
               "translate-x-0": layout.mobileSidebar.opened(),
               "translate-x-0": layout.mobileSidebar.opened(),
               "-translate-x-full": !layout.mobileSidebar.opened(),
               "-translate-x-full": !layout.mobileSidebar.opened(),
             }}
             }}
@@ -2162,16 +2238,66 @@ export default function Layout(props: ParentProps) {
           </nav>
           </nav>
         </div>
         </div>
 
 
-        <main
+        <div
           classList={{
           classList={{
-            "size-full overflow-x-hidden flex flex-col items-start contain-strict border-t border-border-weak-base": true,
-            "xl:border-l xl:rounded-tl-[12px]": !layout.sidebar.opened(),
+            "absolute inset-0": true,
+            "xl:inset-y-0 xl:right-0 xl:left-[var(--main-left)]": true,
+            "z-20": true,
+            "transition-[left] duration-200 ease-[cubic-bezier(0.22,1,0.36,1)] will-change-[left] motion-reduce:transition-none":
+              !sizing(),
+          }}
+          style={{
+            "--main-left": layout.sidebar.opened() ? `${Math.max(layout.sidebar.width(), 244)}px` : "4rem",
           }}
           }}
         >
         >
-          <Show when={!autoselecting()} fallback={<div class="size-full" />}>
-            {props.children}
+          <main
+            classList={{
+              "size-full overflow-x-hidden flex flex-col items-start contain-strict border-t border-border-weak-base bg-background-base xl:border-l xl:rounded-tl-[12px]": true,
+            }}
+          >
+            <Show when={!autoselecting()} fallback={<div class="size-full" />}>
+              {props.children}
+            </Show>
+          </main>
+        </div>
+
+        <div
+          classList={{
+            "hidden xl:flex absolute inset-y-0 left-16 z-30": true,
+            "opacity-100 translate-x-0 pointer-events-auto": peeked() && !layout.sidebar.opened(),
+            "opacity-0 -translate-x-2 pointer-events-none": !peeked() || layout.sidebar.opened(),
+            "transition-[opacity,transform] motion-reduce:transition-none": true,
+            "duration-180 ease-out": peeked() && !layout.sidebar.opened(),
+            "duration-120 ease-in": !peeked() || layout.sidebar.opened(),
+          }}
+          onMouseMove={disarm}
+          onMouseEnter={() => {
+            disarm()
+            aim.reset()
+          }}
+          onPointerDown={disarm}
+          onMouseLeave={() => {
+            arm()
+          }}
+        >
+          <Show when={peek()} keyed>
+            {(project) => <SidebarPanel project={project} merged={false} />}
           </Show>
           </Show>
-        </main>
+        </div>
+
+        <div
+          classList={{
+            "hidden xl:block pointer-events-none absolute inset-y-0 right-0 z-25 overflow-hidden": true,
+            "opacity-100 translate-x-0": peeked() && !layout.sidebar.opened(),
+            "opacity-0 -translate-x-2": !peeked() || layout.sidebar.opened(),
+            "transition-[opacity,transform] motion-reduce:transition-none": true,
+            "duration-180 ease-out": peeked() && !layout.sidebar.opened(),
+            "duration-120 ease-in": !peeked() || layout.sidebar.opened(),
+          }}
+          style={{ left: `calc(4rem + ${Math.max(Math.max(layout.sidebar.width(), 244) - 64, 0)}px)` }}
+        >
+          <div class="h-full w-px" style={{ "box-shadow": "var(--shadow-sidebar-overlay)" }} />
+        </div>
       </div>
       </div>
       <Toast.Region />
       <Toast.Region />
     </div>
     </div>

+ 0 - 1
packages/app/src/pages/layout/sidebar-items.tsx

@@ -163,7 +163,6 @@ const SessionHoverPreview = (props: {
     gutter={16}
     gutter={16}
     shift={-2}
     shift={-2}
     trigger={props.trigger}
     trigger={props.trigger}
-    mount={!props.mobile ? props.nav() : undefined}
     open={props.hoverSession() === props.session.id}
     open={props.hoverSession() === props.session.id}
     onOpenChange={(open) => props.setHoverSession(open ? props.session.id : undefined)}
     onOpenChange={(open) => props.setHoverSession(open ? props.session.id : undefined)}
   >
   >

+ 1 - 18
packages/app/src/pages/layout/sidebar-project.tsx

@@ -5,8 +5,6 @@ import { Button } from "@opencode-ai/ui/button"
 import { ContextMenu } from "@opencode-ai/ui/context-menu"
 import { ContextMenu } from "@opencode-ai/ui/context-menu"
 import { HoverCard } from "@opencode-ai/ui/hover-card"
 import { HoverCard } from "@opencode-ai/ui/hover-card"
 import { Icon } from "@opencode-ai/ui/icon"
 import { Icon } from "@opencode-ai/ui/icon"
-import { IconButton } from "@opencode-ai/ui/icon-button"
-import { Tooltip } from "@opencode-ai/ui/tooltip"
 import { createSortable } from "@thisbeyond/solid-dnd"
 import { createSortable } from "@thisbeyond/solid-dnd"
 import { useLayout, type LocalProject } from "@/context/layout"
 import { useLayout, type LocalProject } from "@/context/layout"
 import { useGlobalSync } from "@/context/global-sync"
 import { useGlobalSync } from "@/context/global-sync"
@@ -137,7 +135,7 @@ const ProjectTile = (props: {
       >
       >
         <ProjectIcon project={props.project} notify />
         <ProjectIcon project={props.project} notify />
       </ContextMenu.Trigger>
       </ContextMenu.Trigger>
-      <ContextMenu.Portal mount={!props.mobile ? props.nav() : undefined}>
+      <ContextMenu.Portal>
         <ContextMenu.Content>
         <ContextMenu.Content>
           <ContextMenu.Item onSelect={() => props.showEditProjectDialog(props.project)}>
           <ContextMenu.Item onSelect={() => props.showEditProjectDialog(props.project)}>
             <ContextMenu.ItemLabel>{props.language.t("common.edit")}</ContextMenu.ItemLabel>
             <ContextMenu.ItemLabel>{props.language.t("common.edit")}</ContextMenu.ItemLabel>
@@ -194,21 +192,6 @@ const ProjectPreviewPanel = (props: {
   <div class="-m-3 p-2 flex flex-col w-72">
   <div class="-m-3 p-2 flex flex-col w-72">
     <div class="px-4 pt-2 pb-1 flex items-center gap-2">
     <div class="px-4 pt-2 pb-1 flex items-center gap-2">
       <div class="text-14-medium text-text-strong truncate grow">{displayName(props.project)}</div>
       <div class="text-14-medium text-text-strong truncate grow">{displayName(props.project)}</div>
-      <Tooltip value={props.language.t("common.close")} placement="top" gutter={6}>
-        <IconButton
-          icon="circle-x"
-          variant="ghost"
-          class="shrink-0"
-          data-action="project-close-hover"
-          data-project={base64Encode(props.project.worktree)}
-          aria-label={props.language.t("common.close")}
-          onClick={(event) => {
-            event.stopPropagation()
-            props.setOpen(false)
-            props.ctx.closeProject(props.project.worktree)
-          }}
-        />
-      </Tooltip>
     </div>
     </div>
     <div class="px-4 pb-2 text-12-medium text-text-weak">{props.language.t("sidebar.project.recentSessions")}</div>
     <div class="px-4 pb-2 text-12-medium text-text-weak">{props.language.t("sidebar.project.recentSessions")}</div>
     <div class="px-2 pb-2 flex flex-col gap-2">
     <div class="px-2 pb-2 flex flex-col gap-2">

+ 23 - 3
packages/app/src/pages/layout/sidebar-shell.tsx

@@ -1,4 +1,4 @@
-import { createMemo, For, Show, type Accessor, type JSX } from "solid-js"
+import { createEffect, createMemo, For, Show, type Accessor, type JSX } from "solid-js"
 import {
 import {
   DragDropProvider,
   DragDropProvider,
   DragDropSensors,
   DragDropSensors,
@@ -35,10 +35,22 @@ export const SidebarContent = (props: {
 }): JSX.Element => {
 }): JSX.Element => {
   const expanded = createMemo(() => sidebarExpanded(props.mobile, props.opened()))
   const expanded = createMemo(() => sidebarExpanded(props.mobile, props.opened()))
   const placement = () => (props.mobile ? "bottom" : "right")
   const placement = () => (props.mobile ? "bottom" : "right")
+  let panel: HTMLDivElement | undefined
+
+  createEffect(() => {
+    const el = panel
+    if (!el) return
+    if (expanded()) {
+      el.removeAttribute("inert")
+      return
+    }
+    el.setAttribute("inert", "")
+  })
 
 
   return (
   return (
-    <div class="flex h-full w-full overflow-hidden">
+    <div class="flex h-full w-full min-w-0 overflow-hidden">
       <div
       <div
+        data-component="sidebar-rail"
         class="w-16 shrink-0 bg-background-base flex flex-col items-center overflow-hidden"
         class="w-16 shrink-0 bg-background-base flex flex-col items-center overflow-hidden"
         onMouseMove={props.aimMove}
         onMouseMove={props.aimMove}
       >
       >
@@ -100,7 +112,15 @@ export const SidebarContent = (props: {
         </div>
         </div>
       </div>
       </div>
 
 
-      <Show when={expanded()}>{props.renderPanel()}</Show>
+      <div
+        ref={(el) => {
+          panel = el
+        }}
+        classList={{ "flex h-full min-h-0 min-w-0 overflow-hidden": true, "pointer-events-none": !expanded() }}
+        aria-hidden={!expanded()}
+      >
+        {props.renderPanel()}
+      </div>
     </div>
     </div>
   )
   )
 }
 }

+ 3 - 3
packages/app/src/pages/layout/sidebar-workspace.tsx

@@ -182,7 +182,7 @@ const WorkspaceActions = (props: {
           aria-label={props.language.t("common.moreOptions")}
           aria-label={props.language.t("common.moreOptions")}
         />
         />
       </Tooltip>
       </Tooltip>
-      <DropdownMenu.Portal mount={!props.mobile ? props.nav() : undefined}>
+      <DropdownMenu.Portal>
         <DropdownMenu.Content
         <DropdownMenu.Content
           onCloseAutoFocus={(event) => {
           onCloseAutoFocus={(event) => {
             if (!props.pendingRename()) return
             if (!props.pendingRename()) return
@@ -249,7 +249,7 @@ const WorkspaceSessionList = (props: {
   loadMore: () => Promise<void>
   loadMore: () => Promise<void>
   language: ReturnType<typeof useLanguage>
   language: ReturnType<typeof useLanguage>
 }): JSX.Element => (
 }): JSX.Element => (
-  <nav class="flex flex-col gap-1 px-2">
+  <nav class="flex flex-col gap-1 px-3">
     <Show when={props.showNew()}>
     <Show when={props.showNew()}>
       <NewSessionItem
       <NewSessionItem
         slug={props.slug()}
         slug={props.slug()}
@@ -490,7 +490,7 @@ export const LocalWorkspace = (props: {
       ref={(el) => props.ctx.setScrollContainerRef(el, props.mobile)}
       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 py-2 overflow-y-auto no-scrollbar [overflow-anchor:none]"
     >
     >
-      <nav class="flex flex-col gap-1 px-2">
+      <nav class="flex flex-col gap-1 px-3">
         <Show when={loading()}>
         <Show when={loading()}>
           <SessionSkeleton />
           <SessionSkeleton />
         </Show>
         </Show>

+ 131 - 69
packages/app/src/pages/session.tsx

@@ -1,4 +1,4 @@
-import type { UserMessage } from "@opencode-ai/sdk/v2"
+import type { Project, UserMessage } from "@opencode-ai/sdk/v2"
 import { useDialog } from "@opencode-ai/ui/context/dialog"
 import { useDialog } from "@opencode-ai/ui/context/dialog"
 import {
 import {
   onCleanup,
   onCleanup,
@@ -20,20 +20,23 @@ import { createStore } from "solid-js/store"
 import { ResizeHandle } from "@opencode-ai/ui/resize-handle"
 import { ResizeHandle } from "@opencode-ai/ui/resize-handle"
 import { Select } from "@opencode-ai/ui/select"
 import { Select } from "@opencode-ai/ui/select"
 import { createAutoScroll } from "@opencode-ai/ui/hooks"
 import { createAutoScroll } from "@opencode-ai/ui/hooks"
-import { Mark } from "@opencode-ai/ui/logo"
+import { Button } from "@opencode-ai/ui/button"
+import { showToast } from "@opencode-ai/ui/toast"
 import { base64Encode, checksum } from "@opencode-ai/util/encode"
 import { base64Encode, checksum } from "@opencode-ai/util/encode"
 import { useNavigate, useParams, useSearchParams } from "@solidjs/router"
 import { useNavigate, useParams, useSearchParams } from "@solidjs/router"
 import { NewSessionView, SessionHeader } from "@/components/session"
 import { NewSessionView, SessionHeader } from "@/components/session"
 import { useComments } from "@/context/comments"
 import { useComments } from "@/context/comments"
+import { useGlobalSync } from "@/context/global-sync"
 import { useLanguage } from "@/context/language"
 import { useLanguage } from "@/context/language"
 import { useLayout } from "@/context/layout"
 import { useLayout } from "@/context/layout"
 import { usePrompt } from "@/context/prompt"
 import { usePrompt } from "@/context/prompt"
 import { useSDK } from "@/context/sdk"
 import { useSDK } from "@/context/sdk"
 import { useSync } from "@/context/sync"
 import { useSync } from "@/context/sync"
 import { createSessionComposerState, SessionComposerRegion } from "@/pages/session/composer"
 import { createSessionComposerState, SessionComposerRegion } from "@/pages/session/composer"
-import { createOpenReviewFile } from "@/pages/session/helpers"
+import { createOpenReviewFile, createSizing } from "@/pages/session/helpers"
 import { MessageTimeline } from "@/pages/session/message-timeline"
 import { MessageTimeline } from "@/pages/session/message-timeline"
 import { type DiffStyle, SessionReviewTab, type SessionReviewTabProps } from "@/pages/session/review-tab"
 import { type DiffStyle, SessionReviewTab, type SessionReviewTabProps } from "@/pages/session/review-tab"
+import { resetSessionModel, syncSessionModel } from "@/pages/session/session-model-helpers"
 import { createScrollSpy } from "@/pages/session/scroll-spy"
 import { createScrollSpy } from "@/pages/session/scroll-spy"
 import { SessionMobileTabs } from "@/pages/session/session-mobile-tabs"
 import { SessionMobileTabs } from "@/pages/session/session-mobile-tabs"
 import { SessionSidePanel } from "@/pages/session/session-side-panel"
 import { SessionSidePanel } from "@/pages/session/session-side-panel"
@@ -41,6 +44,7 @@ import { TerminalPanel } from "@/pages/session/terminal-panel"
 import { useSessionCommands } from "@/pages/session/use-session-commands"
 import { useSessionCommands } from "@/pages/session/use-session-commands"
 import { useSessionHashScroll } from "@/pages/session/use-session-hash-scroll"
 import { useSessionHashScroll } from "@/pages/session/use-session-hash-scroll"
 import { same } from "@/utils/same"
 import { same } from "@/utils/same"
+import { formatServerError } from "@/utils/server-errors"
 
 
 const emptyUserMessages: UserMessage[] = []
 const emptyUserMessages: UserMessage[] = []
 
 
@@ -118,13 +122,9 @@ function createSessionHistoryWindow(input: SessionHistoryWindowInput) {
       return
       return
     }
     }
     const beforeTop = el.scrollTop
     const beforeTop = el.scrollTop
-    const beforeHeight = el.scrollHeight
     fn()
     fn()
-    requestAnimationFrame(() => {
-      const delta = el.scrollHeight - beforeHeight
-      if (!delta) return
-      el.scrollTop = beforeTop + delta
-    })
+    void el.scrollHeight
+    el.scrollTop = beforeTop
   }
   }
 
 
   const backfillTurns = () => {
   const backfillTurns = () => {
@@ -207,7 +207,7 @@ function createSessionHistoryWindow(input: SessionHistoryWindowInput) {
     if (!input.userScrolled()) return
     if (!input.userScrolled()) return
     const el = input.scroller()
     const el = input.scroller()
     if (!el) return
     if (!el) return
-    if (el.scrollTop >= turnScrollThreshold) return
+    if (el.scrollHeight - el.clientHeight + el.scrollTop >= turnScrollThreshold) return
 
 
     const start = turnStart()
     const start = turnStart()
     if (start > 0) {
     if (start > 0) {
@@ -252,6 +252,7 @@ function createSessionHistoryWindow(input: SessionHistoryWindowInput) {
 }
 }
 
 
 export default function Page() {
 export default function Page() {
+  const globalSync = useGlobalSync()
   const layout = useLayout()
   const layout = useLayout()
   const local = useLocal()
   const local = useLocal()
   const file = useFile()
   const file = useFile()
@@ -278,6 +279,7 @@ export default function Page() {
   })
   })
 
 
   const [ui, setUi] = createStore({
   const [ui, setUi] = createStore({
+    git: false,
     pendingMessage: undefined as string | undefined,
     pendingMessage: undefined as string | undefined,
     scrollGesture: 0,
     scrollGesture: 0,
     scroll: {
     scroll: {
@@ -331,6 +333,7 @@ export default function Page() {
   )
   )
 
 
   const isDesktop = createMediaQuery("(min-width: 768px)")
   const isDesktop = createMediaQuery("(min-width: 768px)")
+  const size = createSizing()
   const desktopReviewOpen = createMemo(() => isDesktop() && view().reviewPanel.opened())
   const desktopReviewOpen = createMemo(() => isDesktop() && view().reviewPanel.opened())
   const desktopFileTreeOpen = createMemo(() => isDesktop() && layout.fileTree.opened())
   const desktopFileTreeOpen = createMemo(() => isDesktop() && layout.fileTree.opened())
   const desktopSidePanelOpen = createMemo(() => desktopReviewOpen() || desktopFileTreeOpen())
   const desktopSidePanelOpen = createMemo(() => desktopReviewOpen() || desktopFileTreeOpen())
@@ -416,15 +419,22 @@ export default function Page() {
       () => {
       () => {
         const msg = lastUserMessage()
         const msg = lastUserMessage()
         if (!msg) return
         if (!msg) return
-        if (msg.agent) {
-          local.agent.set(msg.agent)
-          if (local.agent.current()?.model) return
-        }
-        if (msg.model) local.model.set(msg.model)
+        syncSessionModel(local, msg)
       },
       },
     ),
     ),
   )
   )
 
 
+  createEffect(
+    on(
+      () => params.id,
+      (id, prev) => {
+        if (id || !prev) return
+        resetSessionModel(local)
+      },
+      { defer: true },
+    ),
+  )
+
   const [store, setStore] = createStore({
   const [store, setStore] = createStore({
     messageId: undefined as string | undefined,
     messageId: undefined as string | undefined,
     mobileTab: "session" as "session" | "changes",
     mobileTab: "session" as "session" | "changes",
@@ -490,10 +500,51 @@ export default function Page() {
   })
   })
   const reviewEmptyKey = createMemo(() => {
   const reviewEmptyKey = createMemo(() => {
     const project = sync.project
     const project = sync.project
-    if (!project || project.vcs) return "session.review.empty"
-    return "session.review.noVcs"
+    if (project && !project.vcs) return "session.review.noVcs"
+    if (sync.data.config.snapshot === false) return "session.review.noSnapshot"
+    return "session.review.empty"
   })
   })
 
 
+  function upsert(next: Project) {
+    const list = globalSync.data.project
+    sync.set("project", next.id)
+    const idx = list.findIndex((item) => item.id === next.id)
+    if (idx >= 0) {
+      globalSync.set(
+        "project",
+        list.map((item, i) => (i === idx ? { ...item, ...next } : item)),
+      )
+      return
+    }
+    const at = list.findIndex((item) => item.id > next.id)
+    if (at >= 0) {
+      globalSync.set("project", [...list.slice(0, at), next, ...list.slice(at)])
+      return
+    }
+    globalSync.set("project", [...list, next])
+  }
+
+  function initGit() {
+    if (ui.git) return
+    setUi("git", true)
+    void sdk.client.project
+      .initGit()
+      .then((x) => {
+        if (!x.data) return
+        upsert(x.data)
+      })
+      .catch((err) => {
+        showToast({
+          variant: "error",
+          title: language.t("common.requestFailed"),
+          description: formatServerError(err, language.t),
+        })
+      })
+      .finally(() => {
+        setUi("git", false)
+      })
+  }
+
   let inputRef!: HTMLDivElement
   let inputRef!: HTMLDivElement
   let promptDock: HTMLDivElement | undefined
   let promptDock: HTMLDivElement | undefined
   let dockHeight = 0
   let dockHeight = 0
@@ -727,23 +778,28 @@ export default function Page() {
   const changesOptions = ["session", "turn"] as const
   const changesOptions = ["session", "turn"] as const
   const changesOptionsList = [...changesOptions]
   const changesOptionsList = [...changesOptions]
 
 
-  const changesTitle = () => (
-    <Select
-      options={changesOptionsList}
-      current={store.changes}
-      label={(option) =>
-        option === "session" ? language.t("ui.sessionReview.title") : language.t("ui.sessionReview.title.lastTurn")
-      }
-      onSelect={(option) => option && setStore("changes", option)}
-      variant="ghost"
-      size="small"
-      valueClass="text-14-medium"
-    />
-  )
+  const changesTitle = () => {
+    if (!hasReview()) {
+      return null
+    }
+
+    return (
+      <Select
+        options={changesOptionsList}
+        current={store.changes}
+        label={(option) =>
+          option === "session" ? language.t("ui.sessionReview.title") : language.t("ui.sessionReview.title.lastTurn")
+        }
+        onSelect={(option) => option && setStore("changes", option)}
+        variant="ghost"
+        size="small"
+        valueClass="text-14-medium"
+      />
+    )
+  }
 
 
   const emptyTurn = () => (
   const emptyTurn = () => (
-    <div class="h-full pb-30 flex flex-col items-center justify-center text-center gap-6">
-      <Mark class="w-14 opacity-10" />
+    <div class="h-full pb-64 -mt-4 flex flex-col items-center justify-center text-center gap-6">
       <div class="text-14-regular text-text-weak max-w-56">{language.t("session.review.noChanges")}</div>
       <div class="text-14-regular text-text-weak max-w-56">{language.t("session.review.noChanges")}</div>
     </div>
     </div>
   )
   )
@@ -809,9 +865,23 @@ export default function Page() {
             empty={
             empty={
               store.changes === "turn" ? (
               store.changes === "turn" ? (
                 emptyTurn()
                 emptyTurn()
+              ) : reviewEmptyKey() === "session.review.noVcs" ? (
+                <div class={input.emptyClass}>
+                  <div class="flex flex-col gap-3">
+                    <div class="text-14-medium text-text-strong">Create a Git repository</div>
+                    <div
+                      class="text-14-regular text-text-base max-w-md"
+                      style={{ "line-height": "var(--line-height-normal)" }}
+                    >
+                      Track, review, and undo changes in this project
+                    </div>
+                  </div>
+                  <Button size="large" disabled={ui.git} onClick={initGit}>
+                    {ui.git ? "Creating Git repository..." : "Create Git repository"}
+                  </Button>
+                </div>
               ) : (
               ) : (
                 <div class={input.emptyClass}>
                 <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 class="text-14-regular text-text-weak max-w-56">{language.t(reviewEmptyKey())}</div>
                 </div>
                 </div>
               )
               )
@@ -844,7 +914,7 @@ export default function Page() {
           diffStyle: layout.review.diffStyle(),
           diffStyle: layout.review.diffStyle(),
           onDiffStyleChange: layout.review.setDiffStyle,
           onDiffStyleChange: layout.review.setDiffStyle,
           loadingClass: "px-6 py-4 text-text-weak",
           loadingClass: "px-6 py-4 text-text-weak",
-          emptyClass: "h-full pb-30 flex flex-col items-center justify-center text-center gap-6",
+          emptyClass: "h-full pb-64 -mt-4 flex flex-col items-center justify-center text-center gap-6",
         })}
         })}
       </div>
       </div>
     </div>
     </div>
@@ -968,23 +1038,6 @@ export default function Page() {
     tabs().setActive(next)
     tabs().setActive(next)
   })
   })
 
 
-  createEffect(
-    on(
-      () => layout.fileTree.opened(),
-      (opened, prev) => {
-        if (prev === undefined) return
-        if (!isDesktop()) return
-
-        if (opened) {
-          const active = tabs().active()
-          const tab = active === "review" || (!active && hasReview()) ? "changes" : "all"
-          layout.fileTree.setTab(tab)
-        }
-      },
-      { defer: true },
-    ),
-  )
-
   createEffect(() => {
   createEffect(() => {
     const id = params.id
     const id = params.id
     if (!id) return
     if (!id) return
@@ -1045,7 +1098,7 @@ export default function Page() {
   const updateScrollState = (el: HTMLDivElement) => {
   const updateScrollState = (el: HTMLDivElement) => {
     const max = el.scrollHeight - el.clientHeight
     const max = el.scrollHeight - el.clientHeight
     const overflow = max > 1
     const overflow = max > 1
-    const bottom = !overflow || el.scrollTop >= max - 2
+    const bottom = !overflow || Math.abs(el.scrollTop) <= 2 || !autoScroll.userScrolled()
 
 
     if (ui.scroll.overflow === overflow && ui.scroll.bottom === bottom) return
     if (ui.scroll.overflow === overflow && ui.scroll.bottom === bottom) return
     setUi("scroll", { overflow, bottom })
     setUi("scroll", { overflow, bottom })
@@ -1068,7 +1121,7 @@ export default function Page() {
 
 
   const resumeScroll = () => {
   const resumeScroll = () => {
     setStore("messageId", undefined)
     setStore("messageId", undefined)
-    autoScroll.forceScrollToBottom()
+    autoScroll.smoothScrollToBottom()
     clearMessageHash()
     clearMessageHash()
 
 
     const el = scroller
     const el = scroller
@@ -1136,13 +1189,11 @@ export default function Page() {
 
 
       const el = scroller
       const el = scroller
       const delta = next - dockHeight
       const delta = next - dockHeight
-      const stick = el
-        ? !autoScroll.userScrolled() || el.scrollHeight - el.clientHeight - el.scrollTop < 10 + Math.max(0, delta)
-        : false
+      const stick = el ? Math.abs(el.scrollTop) < 10 + Math.max(0, delta) : false
 
 
       dockHeight = next
       dockHeight = next
 
 
-      if (stick) autoScroll.forceScrollToBottom()
+      if (stick) autoScroll.smoothScrollToBottom()
 
 
       if (el) scheduleScrollState(el)
       if (el) scheduleScrollState(el)
       scrollSpy.markDirty()
       scrollSpy.markDirty()
@@ -1193,9 +1244,9 @@ export default function Page() {
         {/* Session panel */}
         {/* Session panel */}
         <div
         <div
           classList={{
           classList={{
-            "@container relative shrink-0 flex flex-col min-h-0 h-full bg-background-stronger": true,
-            "flex-1": true,
-            "md:flex-none": desktopSidePanelOpen(),
+            "@container relative shrink-0 flex flex-col min-h-0 h-full bg-background-stronger flex-1 md:flex-none": true,
+            "transition-[width] duration-[240ms] ease-[cubic-bezier(0.22,1,0.36,1)] will-change-[width] motion-reduce:transition-none":
+              !size.active(),
           }}
           }}
           style={{
           style={{
             width: sessionPanelWidth(),
             width: sessionPanelWidth(),
@@ -1215,7 +1266,7 @@ export default function Page() {
                         container: "px-4",
                         container: "px-4",
                       },
                       },
                       loadingClass: "px-4 py-4 text-text-weak",
                       loadingClass: "px-4 py-4 text-text-weak",
-                      emptyClass: "h-full pb-30 flex flex-col items-center justify-center text-center gap-6",
+                      emptyClass: "h-full pb-64 -mt-4 flex flex-col items-center justify-center text-center gap-6",
                     })}
                     })}
                     scroll={ui.scroll}
                     scroll={ui.scroll}
                     onResumeScroll={resumeScroll}
                     onResumeScroll={resumeScroll}
@@ -1228,6 +1279,7 @@ export default function Page() {
                     onScrollSpyScroll={scrollSpy.onScroll}
                     onScrollSpyScroll={scrollSpy.onScroll}
                     onTurnBackfillScroll={historyWindow.onScrollerScroll}
                     onTurnBackfillScroll={historyWindow.onScrollerScroll}
                     onAutoScrollInteraction={autoScroll.handleInteraction}
                     onAutoScrollInteraction={autoScroll.handleInteraction}
+                    onPreserveScrollAnchor={autoScroll.preserve}
                     centered={centered()}
                     centered={centered()}
                     setContentRef={(el) => {
                     setContentRef={(el) => {
                       content = el
                       content = el
@@ -1291,17 +1343,27 @@ export default function Page() {
           />
           />
 
 
           <Show when={desktopReviewOpen()}>
           <Show when={desktopReviewOpen()}>
-            <ResizeHandle
-              direction="horizontal"
-              size={layout.session.width()}
-              min={450}
-              max={typeof window === "undefined" ? 1000 : window.innerWidth * 0.45}
-              onResize={layout.session.resize}
-            />
+            <div onPointerDown={() => size.start()}>
+              <ResizeHandle
+                direction="horizontal"
+                size={layout.session.width()}
+                min={450}
+                max={typeof window === "undefined" ? 1000 : window.innerWidth * 0.45}
+                onResize={(width) => {
+                  size.touch()
+                  layout.session.resize(width)
+                }}
+              />
+            </div>
           </Show>
           </Show>
         </div>
         </div>
 
 
-        <SessionSidePanel reviewPanel={reviewPanel} activeDiff={tree.activeDiff} focusReviewDiff={focusReviewDiff} />
+        <SessionSidePanel
+          reviewPanel={reviewPanel}
+          activeDiff={tree.activeDiff}
+          focusReviewDiff={focusReviewDiff}
+          size={size}
+        />
       </div>
       </div>
 
 
       <TerminalPanel />
       <TerminalPanel />

+ 1 - 1
packages/app/src/pages/session/composer/session-composer-region.tsx

@@ -140,7 +140,7 @@ export function SessionComposerRegion(props: {
       <div
       <div
         classList={{
         classList={{
           "w-full px-3 pointer-events-auto": true,
           "w-full px-3 pointer-events-auto": true,
-          "md:max-w-200 md:mx-auto 2xl:max-w-[1000px]": props.centered,
+          "md:max-w-[500px] md:mx-auto 2xl:max-w-[700px]": props.centered,
         }}
         }}
       >
       >
         <Show when={props.state.questionRequest()} keyed>
         <Show when={props.state.questionRequest()} keyed>

+ 2 - 2
packages/app/src/pages/session/file-tabs.tsx

@@ -446,9 +446,9 @@ export function FileTabContent(props: { tab: string }) {
   )
   )
 
 
   return (
   return (
-    <Tabs.Content value={props.tab} class="mt-3 relative h-full">
+    <Tabs.Content value={props.tab} class="mt-3 relative flex h-full min-h-0 flex-col overflow-hidden contain-strict">
       <ScrollView
       <ScrollView
-        class="h-full"
+        class="h-full min-h-0 flex-1"
         viewportRef={(el: HTMLDivElement) => {
         viewportRef={(el: HTMLDivElement) => {
           scroll = el
           scroll = el
           restoreScroll()
           restoreScroll()

+ 103 - 1
packages/app/src/pages/session/helpers.ts

@@ -1,4 +1,5 @@
-import { batch } from "solid-js"
+import { batch, createEffect, on, onCleanup, onMount, type Accessor } from "solid-js"
+import { createStore } from "solid-js/store"
 
 
 export const focusTerminalById = (id: string) => {
 export const focusTerminalById = (id: string) => {
   const wrapper = document.getElementById(`terminal-wrapper-${id}`)
   const wrapper = document.getElementById(`terminal-wrapper-${id}`)
@@ -69,3 +70,104 @@ export const getTabReorderIndex = (tabs: readonly string[], from: string, to: st
   if (fromIndex === -1 || toIndex === -1 || fromIndex === toIndex) return undefined
   if (fromIndex === -1 || toIndex === -1 || fromIndex === toIndex) return undefined
   return toIndex
   return toIndex
 }
 }
+
+export const createSizing = () => {
+  const [state, setState] = createStore({ active: false })
+  let t: number | undefined
+
+  const stop = () => {
+    if (t !== undefined) {
+      clearTimeout(t)
+      t = undefined
+    }
+    setState("active", false)
+  }
+
+  const start = () => {
+    if (t !== undefined) {
+      clearTimeout(t)
+      t = undefined
+    }
+    setState("active", true)
+  }
+
+  onMount(() => {
+    window.addEventListener("pointerup", stop)
+    window.addEventListener("pointercancel", stop)
+    window.addEventListener("blur", stop)
+    onCleanup(() => {
+      window.removeEventListener("pointerup", stop)
+      window.removeEventListener("pointercancel", stop)
+      window.removeEventListener("blur", stop)
+    })
+  })
+
+  onCleanup(() => {
+    if (t !== undefined) clearTimeout(t)
+  })
+
+  return {
+    active: () => state.active,
+    start,
+    touch() {
+      start()
+      t = window.setTimeout(stop, 120)
+    },
+  }
+}
+
+export type Sizing = ReturnType<typeof createSizing>
+
+export const createPresence = (open: Accessor<boolean>, wait = 200) => {
+  const [state, setState] = createStore({
+    show: open(),
+    open: open(),
+  })
+  let frame: number | undefined
+  let t: number | undefined
+
+  const clear = () => {
+    if (frame !== undefined) {
+      cancelAnimationFrame(frame)
+      frame = undefined
+    }
+    if (t !== undefined) {
+      clearTimeout(t)
+      t = undefined
+    }
+  }
+
+  createEffect(
+    on(open, (next) => {
+      clear()
+
+      if (next) {
+        if (state.show) {
+          setState("open", true)
+          return
+        }
+
+        setState({ show: true, open: false })
+        frame = requestAnimationFrame(() => {
+          frame = undefined
+          setState("open", true)
+        })
+        return
+      }
+
+      if (!state.show) return
+      setState("open", false)
+      t = window.setTimeout(() => {
+        t = undefined
+        setState("show", false)
+      }, wait)
+    }),
+  )
+
+  onCleanup(clear)
+
+  return {
+    show: () => state.show,
+    open: () => state.open,
+  }
+}

+ 133 - 373
packages/app/src/pages/session/message-timeline.tsx

@@ -1,27 +1,31 @@
-import { For, createEffect, createMemo, on, onCleanup, Show, Index, type JSX } from "solid-js"
-import { createStore, produce } from "solid-js/store"
-import { useNavigate, useParams } from "@solidjs/router"
+import {
+  For,
+  Index,
+  createEffect,
+  createMemo,
+  createSignal,
+  on,
+  onCleanup,
+  Show,
+  startTransition,
+  type JSX,
+} from "solid-js"
+import { createStore } from "solid-js/store"
+import { useParams } from "@solidjs/router"
 import { Button } from "@opencode-ai/ui/button"
 import { Button } from "@opencode-ai/ui/button"
 import { FileIcon } from "@opencode-ai/ui/file-icon"
 import { FileIcon } from "@opencode-ai/ui/file-icon"
 import { Icon } from "@opencode-ai/ui/icon"
 import { Icon } from "@opencode-ai/ui/icon"
-import { IconButton } from "@opencode-ai/ui/icon-button"
-import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu"
-import { Dialog } from "@opencode-ai/ui/dialog"
-import { InlineInput } from "@opencode-ai/ui/inline-input"
 import { SessionTurn } from "@opencode-ai/ui/session-turn"
 import { SessionTurn } from "@opencode-ai/ui/session-turn"
 import { ScrollView } from "@opencode-ai/ui/scroll-view"
 import { ScrollView } from "@opencode-ai/ui/scroll-view"
 import type { AssistantMessage, Message as MessageType, Part, TextPart, UserMessage } from "@opencode-ai/sdk/v2"
 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 { Binary } from "@opencode-ai/util/binary"
 import { getFilename } from "@opencode-ai/util/path"
 import { getFilename } from "@opencode-ai/util/path"
 import { shouldMarkBoundaryGesture, normalizeWheelDelta } from "@/pages/session/message-gesture"
 import { shouldMarkBoundaryGesture, normalizeWheelDelta } from "@/pages/session/message-gesture"
-import { SessionContextUsage } from "@/components/session-context-usage"
-import { useDialog } from "@opencode-ai/ui/context/dialog"
 import { useLanguage } from "@/context/language"
 import { useLanguage } from "@/context/language"
 import { useSettings } from "@/context/settings"
 import { useSettings } from "@/context/settings"
-import { useSDK } from "@/context/sdk"
 import { useSync } from "@/context/sync"
 import { useSync } from "@/context/sync"
 import { parseCommentNote, readCommentMetadata } from "@/utils/comment-note"
 import { parseCommentNote, readCommentMetadata } from "@/utils/comment-note"
+import { SessionTimelineHeader } from "@/pages/session/session-timeline-header"
 
 
 type MessageComment = {
 type MessageComment = {
   path: string
   path: string
@@ -33,7 +37,9 @@ type MessageComment = {
 }
 }
 
 
 const emptyMessages: MessageType[] = []
 const emptyMessages: MessageType[] = []
-const idle = { type: "idle" as const }
+
+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[] =>
 const messageComments = (parts: Part[]): MessageComment[] =>
   parts.flatMap((part) => {
   parts.flatMap((part) => {
@@ -110,6 +116,8 @@ function createTimelineStaging(input: TimelineStageInput) {
     completedSession: "",
     completedSession: "",
     count: 0,
     count: 0,
   })
   })
+  const [readySession, setReadySession] = createSignal("")
+  let active = ""
 
 
   const stagedCount = createMemo(() => {
   const stagedCount = createMemo(() => {
     const total = input.messages().length
     const total = input.messages().length
@@ -134,23 +142,46 @@ function createTimelineStaging(input: TimelineStageInput) {
     cancelAnimationFrame(frame)
     cancelAnimationFrame(frame)
     frame = undefined
     frame = undefined
   }
   }
+  const scheduleReady = (sessionKey: string) => {
+    if (input.sessionKey() !== sessionKey) return
+    if (readySession() === sessionKey) return
+    setReadySession(sessionKey)
+  }
 
 
   createEffect(
   createEffect(
     on(
     on(
       () => [input.sessionKey(), input.turnStart() > 0, input.messages().length] as const,
       () => [input.sessionKey(), input.turnStart() > 0, input.messages().length] as const,
       ([sessionKey, isWindowed, total]) => {
       ([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()
         cancel()
-        const shouldStage =
-          isWindowed &&
-          total > input.config.init &&
-          state.completedSession !== sessionKey &&
-          state.activeSession !== sessionKey
+
+        if (shouldStage) setReadySession("")
         if (!shouldStage) {
         if (!shouldStage) {
-          setState({ activeSession: "", count: total })
+          setState({
+            activeSession: "",
+            completedSession: isWindowed ? sessionKey : state.completedSession,
+            count: total,
+          })
+          if (total <= 0) {
+            setReadySession("")
+            return
+          }
+          if (readySession() !== sessionKey) scheduleReady(sessionKey)
           return
           return
         }
         }
 
 
         let count = Math.min(total, input.config.init)
         let count = Math.min(total, input.config.init)
+        if (staging) count = Math.min(total, Math.max(count, state.count))
         setState({ activeSession: sessionKey, count })
         setState({ activeSession: sessionKey, count })
 
 
         const step = () => {
         const step = () => {
@@ -160,10 +191,11 @@ function createTimelineStaging(input: TimelineStageInput) {
           }
           }
           const currentTotal = input.messages().length
           const currentTotal = input.messages().length
           count = Math.min(currentTotal, count + input.config.batch)
           count = Math.min(currentTotal, count + input.config.batch)
-          setState("count", count)
+          startTransition(() => setState("count", count))
           if (count >= currentTotal) {
           if (count >= currentTotal) {
             setState({ completedSession: sessionKey, activeSession: "" })
             setState({ completedSession: sessionKey, activeSession: "" })
             frame = undefined
             frame = undefined
+            scheduleReady(sessionKey)
             return
             return
           }
           }
           frame = requestAnimationFrame(step)
           frame = requestAnimationFrame(step)
@@ -177,9 +209,12 @@ function createTimelineStaging(input: TimelineStageInput) {
     const key = input.sessionKey()
     const key = input.sessionKey()
     return state.activeSession === key && state.completedSession !== key
     return state.activeSession === key && state.completedSession !== key
   })
   })
+  const ready = createMemo(() => readySession() === input.sessionKey())
 
 
-  onCleanup(cancel)
-  return { messages: stagedUserMessages, isStaging }
+  onCleanup(() => {
+    cancel()
+  })
+  return { messages: stagedUserMessages, isStaging, ready }
 }
 }
 
 
 export function MessageTimeline(props: {
 export function MessageTimeline(props: {
@@ -196,6 +231,7 @@ export function MessageTimeline(props: {
   onScrollSpyScroll: () => void
   onScrollSpyScroll: () => void
   onTurnBackfillScroll: () => void
   onTurnBackfillScroll: () => void
   onAutoScrollInteraction: (event: MouseEvent) => void
   onAutoScrollInteraction: (event: MouseEvent) => void
+  onPreserveScrollAnchor: (target: HTMLElement) => void
   centered: boolean
   centered: boolean
   setContentRef: (el: HTMLDivElement) => void
   setContentRef: (el: HTMLDivElement) => void
   turnStart: number
   turnStart: number
@@ -210,14 +246,19 @@ export function MessageTimeline(props: {
   let touchGesture: number | undefined
   let touchGesture: number | undefined
 
 
   const params = useParams()
   const params = useParams()
-  const navigate = useNavigate()
-  const sdk = useSDK()
   const sync = useSync()
   const sync = useSync()
   const settings = useSettings()
   const settings = useSettings()
-  const dialog = useDialog()
   const language = useLanguage()
   const language = useLanguage()
 
 
-  const rendered = createMemo(() => props.renderedUserMessages.map((message) => message.id))
+  const trigger = (target: EventTarget | null) => {
+    const next =
+      target instanceof Element
+        ? target.closest('[data-slot="collapsible-trigger"], [data-slot="accordion-trigger"], [data-scroll-preserve]')
+        : undefined
+    if (!(next instanceof HTMLElement)) return
+    return next
+  }
+
   const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`)
   const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`)
   const sessionID = createMemo(() => params.id)
   const sessionID = createMemo(() => params.id)
   const sessionMessages = createMemo(() => {
   const sessionMessages = createMemo(() => {
@@ -230,28 +271,20 @@ export function MessageTimeline(props: {
       (item): item is AssistantMessage => item.role === "assistant" && typeof item.time.completed !== "number",
       (item): item is AssistantMessage => item.role === "assistant" && typeof item.time.completed !== "number",
     ),
     ),
   )
   )
-  const sessionStatus = createMemo(() => {
-    const id = sessionID()
-    if (!id) return idle
-    return sync.data.session_status[id] ?? idle
-  })
+  const sessionStatus = createMemo(() => sync.data.session_status[sessionID() ?? ""]?.type ?? "idle")
   const activeMessageID = createMemo(() => {
   const activeMessageID = createMemo(() => {
-    const parentID = pending()?.parentID
-    if (parentID) {
-      const messages = sessionMessages()
-      const result = Binary.search(messages, parentID, (message) => message.id)
-      const message = result.found ? messages[result.index] : messages.find((item) => item.id === parentID)
-      if (message && message.role === "user") return message.id
+    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
     }
     }
 
 
-    const status = sessionStatus()
-    if (status.type !== "idle") {
-      const messages = sessionMessages()
-      for (let i = messages.length - 1; i >= 0; i--) {
-        if (messages[i].role === "user") return messages[i].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
     return undefined
   })
   })
   const info = createMemo(() => {
   const info = createMemo(() => {
@@ -259,9 +292,19 @@ export function MessageTimeline(props: {
     if (!id) return
     if (!id) return
     return sync.session.get(id)
     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 parentID = createMemo(() => info()?.parentID)
-  const showHeader = createMemo(() => !!(titleValue() || parentID()))
+  const showHeader = createMemo(() => !!(headerTitle() || parentID()))
   const stageCfg = { init: 1, batch: 3 }
   const stageCfg = { init: 1, batch: 3 }
   const staging = createTimelineStaging({
   const staging = createTimelineStaging({
     sessionKey,
     sessionKey,
@@ -269,212 +312,7 @@ export function MessageTimeline(props: {
     messages: () => props.renderedUserMessages,
     messages: () => props.renderedUserMessages,
     config: stageCfg,
     config: stageCfg,
   })
   })
-
-  const [title, setTitle] = createStore({
-    draft: "",
-    editing: false,
-    saving: false,
-    menuOpen: false,
-    pendingRename: false,
-  })
-  let titleRef: HTMLInputElement | 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")
-  }
-
-  createEffect(
-    on(
-      sessionKey,
-      () => setTitle({ draft: "", editing: false, saving: false, menuOpen: false, pendingRename: false }),
-      { defer: true },
-    ),
-  )
-
-  const openTitleEditor = () => {
-    if (!sessionID()) return
-    setTitle({ editing: true, draft: titleValue() ?? "" })
-    requestAnimationFrame(() => {
-      titleRef?.focus()
-      titleRef?.select()
-    })
-  }
-
-  const closeTitleEditor = () => {
-    if (title.saving) return
-    setTitle({ editing: false, saving: false })
-  }
-
-  const saveTitleEditor = async () => {
-    const id = sessionID()
-    if (!id) return
-    if (title.saving) return
-
-    const next = title.draft.trim()
-    if (!next || next === (titleValue() ?? "")) {
-      setTitle({ editing: false, saving: false })
-      return
-    }
-
-    setTitle("saving", true)
-    await sdk.client.session
-      .update({ sessionID: id, title: next })
-      .then(() => {
-        sync.set(
-          produce((draft) => {
-            const index = draft.session.findIndex((s) => s.id === id)
-            if (index !== -1) draft.session[index].title = next
-          }),
-        )
-        setTitle({ editing: false, saving: false })
-      })
-      .catch((err) => {
-        setTitle("saving", false)
-        showToast({
-          title: language.t("common.requestFailed"),
-          description: errorMessage(err),
-        })
-      })
-  }
-
-  const navigateAfterSessionRemoval = (sessionID: string, parentID?: string, nextSessionID?: string) => {
-    if (params.id !== sessionID) return
-    if (parentID) {
-      navigate(`/${params.dir}/session/${parentID}`)
-      return
-    }
-    if (nextSessionID) {
-      navigate(`/${params.dir}/session/${nextSessionID}`)
-      return
-    }
-    navigate(`/${params.dir}/session`)
-  }
-
-  const archiveSession = async (sessionID: string) => {
-    const session = sync.session.get(sessionID)
-    if (!session) return
-
-    const sessions = sync.data.session ?? []
-    const index = sessions.findIndex((s) => s.id === sessionID)
-    const nextSession = index === -1 ? undefined : (sessions[index + 1] ?? sessions[index - 1])
-
-    await sdk.client.session
-      .update({ sessionID, time: { archived: Date.now() } })
-      .then(() => {
-        sync.set(
-          produce((draft) => {
-            const index = draft.session.findIndex((s) => s.id === sessionID)
-            if (index !== -1) draft.session.splice(index, 1)
-          }),
-        )
-        navigateAfterSessionRemoval(sessionID, session.parentID, nextSession?.id)
-      })
-      .catch((err) => {
-        showToast({
-          title: language.t("common.requestFailed"),
-          description: errorMessage(err),
-        })
-      })
-  }
-
-  const deleteSession = async (sessionID: string) => {
-    const session = sync.session.get(sessionID)
-    if (!session) return false
-
-    const sessions = (sync.data.session ?? []).filter((s) => !s.parentID && !s.time?.archived)
-    const index = sessions.findIndex((s) => s.id === sessionID)
-    const nextSession = index === -1 ? undefined : (sessions[index + 1] ?? sessions[index - 1])
-
-    const result = await sdk.client.session
-      .delete({ sessionID })
-      .then((x) => x.data)
-      .catch((err) => {
-        showToast({
-          title: language.t("session.delete.failed.title"),
-          description: errorMessage(err),
-        })
-        return false
-      })
-
-    if (!result) return false
-
-    sync.set(
-      produce((draft) => {
-        const removed = new Set<string>([sessionID])
-
-        const byParent = new Map<string, string[]>()
-        for (const item of draft.session) {
-          const parentID = item.parentID
-          if (!parentID) continue
-          const existing = byParent.get(parentID)
-          if (existing) {
-            existing.push(item.id)
-            continue
-          }
-          byParent.set(parentID, [item.id])
-        }
-
-        const stack = [sessionID]
-        while (stack.length) {
-          const parentID = stack.pop()
-          if (!parentID) continue
-
-          const children = byParent.get(parentID)
-          if (!children) continue
-
-          for (const child of children) {
-            if (removed.has(child)) continue
-            removed.add(child)
-            stack.push(child)
-          }
-        }
-
-        draft.session = draft.session.filter((s) => !removed.has(s.id))
-      }),
-    )
-
-    navigateAfterSessionRemoval(sessionID, session.parentID, nextSession?.id)
-    return true
-  }
-
-  const navigateParent = () => {
-    const id = parentID()
-    if (!id) return
-    navigate(`/${params.dir}/session/${id}`)
-  }
-
-  function DialogDeleteSession(props: { sessionID: string }) {
-    const name = createMemo(() => sync.session.get(props.sessionID)?.title ?? language.t("command.session.new"))
-    const handleDelete = async () => {
-      await deleteSession(props.sessionID)
-      dialog.close()
-    }
-
-    return (
-      <Dialog title={language.t("session.delete.title")} fit>
-        <div class="flex flex-col gap-4 pl-6 pr-2.5 pb-3">
-          <div class="flex flex-col gap-1">
-            <span class="text-14-regular text-text-strong">
-              {language.t("session.delete.confirm", { name: name() })}
-            </span>
-          </div>
-          <div class="flex justify-end gap-2">
-            <Button variant="ghost" size="large" onClick={() => dialog.close()}>
-              {language.t("common.cancel")}
-            </Button>
-            <Button variant="primary" size="large" onClick={handleDelete}>
-              {language.t("session.delete.button")}
-            </Button>
-          </div>
-        </div>
-      </Dialog>
-    )
-  }
+  const rendered = createMemo(() => staging.messages().map((message) => message.id))
 
 
   return (
   return (
     <Show
     <Show
@@ -498,7 +336,18 @@ export function MessageTimeline(props: {
             <Icon name="arrow-down-to-line" />
             <Icon name="arrow-down-to-line" />
           </button>
           </button>
         </div>
         </div>
+        <SessionTimelineHeader
+          centered={props.centered}
+          showHeader={showHeader}
+          sessionKey={sessionKey}
+          sessionID={sessionID}
+          parentID={parentID}
+          titleValue={titleValue}
+          headerTitle={headerTitle}
+          placeholderTitle={placeholderTitle}
+        />
         <ScrollView
         <ScrollView
+          reverse
           viewportRef={props.setScrollRef}
           viewportRef={props.setScrollRef}
           onWheel={(e) => {
           onWheel={(e) => {
             const root = e.currentTarget
             const root = e.currentTarget
@@ -532,9 +381,18 @@ export function MessageTimeline(props: {
             touchGesture = undefined
             touchGesture = undefined
           }}
           }}
           onPointerDown={(e) => {
           onPointerDown={(e) => {
+            const next = trigger(e.target)
+            if (next) props.onPreserveScrollAnchor(next)
+
             if (e.target !== e.currentTarget) return
             if (e.target !== e.currentTarget) return
             props.onMarkScrollGesture(e.currentTarget)
             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) => {
           onScroll={(e) => {
             props.onScheduleScrollState(e.currentTarget)
             props.onScheduleScrollState(e.currentTarget)
             props.onTurnBackfillScroll()
             props.onTurnBackfillScroll()
@@ -543,134 +401,24 @@ export function MessageTimeline(props: {
             props.onMarkScrollGesture(e.currentTarget)
             props.onMarkScrollGesture(e.currentTarget)
             if (props.isDesktop) props.onScrollSpyScroll()
             if (props.isDesktop) props.onScrollSpyScroll()
           }}
           }}
-          onClick={props.onAutoScrollInteraction}
+          onClick={(e) => {
+            props.onAutoScrollInteraction(e)
+          }}
           class="relative min-w-0 w-full h-full"
           class="relative min-w-0 w-full h-full"
           style={{
           style={{
-            "--session-title-height": showHeader() ? "40px" : "0px",
+            "--session-title-height": showHeader() ? "72px" : "0px",
             "--sticky-accordion-top": showHeader() ? "48px" : "0px",
             "--sticky-accordion-top": showHeader() ? "48px" : "0px",
           }}
           }}
         >
         >
-          <div ref={props.setContentRef} class="min-w-0 w-full">
-            <Show when={showHeader()}>
-              <div
-                data-session-title
-                classList={{
-                  "sticky top-0 z-30 bg-[linear-gradient(to_bottom,var(--background-stronger)_48px,transparent)]": true,
-                  "w-full": true,
-                  "pb-4": 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="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")}
-                      />
-                    </Show>
-                    <Show when={titleValue() || 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>
-                        }
-                      >
-                        <InlineInput
-                          ref={(el) => {
-                            titleRef = el
-                          }}
-                          value={title.draft}
-                          disabled={title.saving}
-                          class="text-14-medium text-text-strong grow-1 min-w-0 pl-2 rounded-[6px]"
-                          style={{ "--inline-input-shadow": "var(--shadow-xs-border-select)" }}
-                          onInput={(event) => setTitle("draft", event.currentTarget.value)}
-                          onKeyDown={(event) => {
-                            event.stopPropagation()
-                            if (event.key === "Enter") {
-                              event.preventDefault()
-                              void saveTitleEditor()
-                              return
-                            }
-                            if (event.key === "Escape") {
-                              event.preventDefault()
-                              closeTitleEditor()
-                            }
-                          }}
-                          onBlur={closeTitleEditor}
-                        />
-                      </Show>
-                    </Show>
-                  </div>
-                  <Show when={sessionID()} keyed>
-                    {(id) => (
-                      <div class="shrink-0 flex items-center gap-3">
-                        <SessionContextUsage placement="bottom" />
-                        <DropdownMenu
-                          gutter={4}
-                          placement="bottom-end"
-                          open={title.menuOpen}
-                          onOpenChange={(open) => setTitle("menuOpen", open)}
-                        >
-                          <DropdownMenu.Trigger
-                            as={IconButton}
-                            icon="dot-grid"
-                            variant="ghost"
-                            class="size-6 rounded-md data-[expanded]:bg-surface-base-active"
-                            aria-label={language.t("common.moreOptions")}
-                          />
-                          <DropdownMenu.Portal>
-                            <DropdownMenu.Content
-                              style={{ "min-width": "104px" }}
-                              onCloseAutoFocus={(event) => {
-                                if (!title.pendingRename) return
-                                event.preventDefault()
-                                setTitle("pendingRename", false)
-                                openTitleEditor()
-                              }}
-                            >
-                              <DropdownMenu.Item
-                                onSelect={() => {
-                                  setTitle("pendingRename", true)
-                                  setTitle("menuOpen", false)
-                                }}
-                              >
-                                <DropdownMenu.ItemLabel>{language.t("common.rename")}</DropdownMenu.ItemLabel>
-                              </DropdownMenu.Item>
-                              <DropdownMenu.Item onSelect={() => void archiveSession(id)}>
-                                <DropdownMenu.ItemLabel>{language.t("common.archive")}</DropdownMenu.ItemLabel>
-                              </DropdownMenu.Item>
-                              <DropdownMenu.Separator />
-                              <DropdownMenu.Item
-                                onSelect={() => dialog.show(() => <DialogDeleteSession sessionID={id} />)}
-                              >
-                                <DropdownMenu.ItemLabel>{language.t("common.delete")}</DropdownMenu.ItemLabel>
-                              </DropdownMenu.Item>
-                            </DropdownMenu.Content>
-                          </DropdownMenu.Portal>
-                        </DropdownMenu>
-                      </div>
-                    )}
-                  </Show>
-                </div>
-              </div>
-            </Show>
-
+          <div>
             <div
             <div
+              ref={props.setContentRef}
               role="log"
               role="log"
-              class="flex flex-col gap-12 items-start justify-start pb-16 transition-[margin]"
+              class="flex flex-col gap-0 items-start justify-start pb-16 transition-[margin]"
+              style={{ "padding-top": "var(--session-title-height)" }}
               classList={{
               classList={{
                 "w-full": true,
                 "w-full": true,
-                "md:max-w-200 md:mx-auto 2xl:max-w-[1000px]": props.centered,
+                "md:max-w-[500px] md:mx-auto 2xl:max-w-[700px]": props.centered,
                 "mt-0.5": props.centered,
                 "mt-0.5": props.centered,
                 "mt-0": !props.centered,
                 "mt-0": !props.centered,
               }}
               }}
@@ -692,6 +440,15 @@ export function MessageTimeline(props: {
               </Show>
               </Show>
               <For each={rendered()}>
               <For each={rendered()}>
                 {(messageID) => {
                 {(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 active = createMemo(() => activeMessageID() === messageID)
                   const queued = createMemo(() => {
                   const queued = createMemo(() => {
                     if (active()) return false
                     if (active()) return false
@@ -700,7 +457,10 @@ export function MessageTimeline(props: {
                     return false
                     return false
                   })
                   })
                   const comments = createMemo(() => messageComments(sync.data.part[messageID] ?? []), [], {
                   const comments = createMemo(() => messageComments(sync.data.part[messageID] ?? []), [], {
-                    equals: (a, b) => JSON.stringify(a) === JSON.stringify(b),
+                    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)
                   const commentCount = createMemo(() => comments().length)
                   return (
                   return (
@@ -713,7 +473,7 @@ export function MessageTimeline(props: {
                       }}
                       }}
                       classList={{
                       classList={{
                         "min-w-0 w-full max-w-full": true,
                         "min-w-0 w-full max-w-full": true,
-                        "md:max-w-200 2xl:max-w-[1000px]": props.centered,
+                        "md:max-w-[500px] 2xl:max-w-[700px]": props.centered,
                       }}
                       }}
                     >
                     >
                       <Show when={commentCount() > 0}>
                       <Show when={commentCount() > 0}>
@@ -757,7 +517,7 @@ export function MessageTimeline(props: {
                         messageID={messageID}
                         messageID={messageID}
                         active={active()}
                         active={active()}
                         queued={queued()}
                         queued={queued()}
-                        status={active() ? sessionStatus() : undefined}
+                        animate={isNew || active()}
                         showReasoningSummaries={settings.general.showReasoningSummaries()}
                         showReasoningSummaries={settings.general.showReasoningSummaries()}
                         shellToolDefaultOpen={settings.general.shellToolPartsExpanded()}
                         shellToolDefaultOpen={settings.general.shellToolPartsExpanded()}
                         editToolDefaultOpen={settings.general.editToolPartsExpanded()}
                         editToolDefaultOpen={settings.general.editToolPartsExpanded()}

+ 158 - 0
packages/app/src/pages/session/session-model-helpers.test.ts

@@ -0,0 +1,158 @@
+import { describe, expect, test } from "bun:test"
+import type { UserMessage } from "@opencode-ai/sdk/v2"
+import { resetSessionModel, syncSessionModel } from "./session-model-helpers"
+
+const message = (input?: Partial<Pick<UserMessage, "agent" | "model" | "variant">>) =>
+  ({
+    id: "msg",
+    sessionID: "session",
+    role: "user",
+    time: { created: 1 },
+    agent: input?.agent ?? "build",
+    model: input?.model ?? { providerID: "anthropic", modelID: "claude-sonnet-4" },
+    variant: input?.variant,
+  }) as UserMessage
+
+describe("syncSessionModel", () => {
+  test("restores the last message model and variant", () => {
+    const calls: unknown[] = []
+
+    syncSessionModel(
+      {
+        agent: {
+          current() {
+            return undefined
+          },
+          set(value) {
+            calls.push(["agent", value])
+          },
+        },
+        model: {
+          set(value) {
+            calls.push(["model", value])
+          },
+          current() {
+            return { id: "claude-sonnet-4", provider: { id: "anthropic" } }
+          },
+          variant: {
+            set(value) {
+              calls.push(["variant", value])
+            },
+          },
+        },
+      },
+      message({ variant: "high" }),
+    )
+
+    expect(calls).toEqual([
+      ["agent", "build"],
+      ["model", { providerID: "anthropic", modelID: "claude-sonnet-4" }],
+      ["variant", "high"],
+    ])
+  })
+
+  test("skips variant when the model falls back", () => {
+    const calls: unknown[] = []
+
+    syncSessionModel(
+      {
+        agent: {
+          current() {
+            return undefined
+          },
+          set(value) {
+            calls.push(["agent", value])
+          },
+        },
+        model: {
+          set(value) {
+            calls.push(["model", value])
+          },
+          current() {
+            return { id: "gpt-5", provider: { id: "openai" } }
+          },
+          variant: {
+            set(value) {
+              calls.push(["variant", value])
+            },
+          },
+        },
+      },
+      message({ variant: "high" }),
+    )
+
+    expect(calls).toEqual([
+      ["agent", "build"],
+      ["model", { providerID: "anthropic", modelID: "claude-sonnet-4" }],
+    ])
+  })
+})
+
+describe("resetSessionModel", () => {
+  test("restores the current agent defaults", () => {
+    const calls: unknown[] = []
+
+    resetSessionModel({
+      agent: {
+        current() {
+          return {
+            model: { providerID: "anthropic", modelID: "claude-sonnet-4" },
+            variant: "high",
+          }
+        },
+        set() {},
+      },
+      model: {
+        set(value) {
+          calls.push(["model", value])
+        },
+        current() {
+          return undefined
+        },
+        variant: {
+          set(value) {
+            calls.push(["variant", value])
+          },
+        },
+      },
+    })
+
+    expect(calls).toEqual([
+      ["model", { providerID: "anthropic", modelID: "claude-sonnet-4" }],
+      ["variant", "high"],
+    ])
+  })
+
+  test("clears the variant when the agent has none", () => {
+    const calls: unknown[] = []
+
+    resetSessionModel({
+      agent: {
+        current() {
+          return {
+            model: { providerID: "anthropic", modelID: "claude-sonnet-4" },
+          }
+        },
+        set() {},
+      },
+      model: {
+        set(value) {
+          calls.push(["model", value])
+        },
+        current() {
+          return undefined
+        },
+        variant: {
+          set(value) {
+            calls.push(["variant", value])
+          },
+        },
+      },
+    })
+
+    expect(calls).toEqual([
+      ["model", { providerID: "anthropic", modelID: "claude-sonnet-4" }],
+      ["variant", undefined],
+    ])
+  })
+})

+ 48 - 0
packages/app/src/pages/session/session-model-helpers.ts

@@ -0,0 +1,48 @@
+import type { UserMessage } from "@opencode-ai/sdk/v2"
+import { batch } from "solid-js"
+
+type Local = {
+  agent: {
+    current():
+      | {
+          model?: UserMessage["model"]
+          variant?: string
+        }
+      | undefined
+    set(name: string | undefined): void
+  }
+  model: {
+    set(model: UserMessage["model"] | undefined): void
+    current():
+      | {
+          id: string
+          provider: { id: string }
+        }
+      | undefined
+    variant: {
+      set(value: string | undefined): void
+    }
+  }
+}
+
+export const resetSessionModel = (local: Local) => {
+  const agent = local.agent.current()
+  if (!agent) return
+  batch(() => {
+    local.model.set(agent.model)
+    local.model.variant.set(agent.variant)
+  })
+}
+
+export const syncSessionModel = (local: Local, msg: UserMessage) => {
+  batch(() => {
+    local.agent.set(msg.agent)
+    local.model.set(msg.model)
+  })
+
+  const model = local.model.current()
+  if (!model) return
+  if (model.provider.id !== msg.model.providerID) return
+  if (model.id !== msg.model.modelID) return
+  local.model.variant.set(msg.variant)
+}

+ 217 - 177
packages/app/src/pages/session/session-side-panel.tsx

@@ -23,7 +23,7 @@ import { useLayout } from "@/context/layout"
 import { useSync } from "@/context/sync"
 import { useSync } from "@/context/sync"
 import { createFileTabListSync } from "@/pages/session/file-tab-scroll"
 import { createFileTabListSync } from "@/pages/session/file-tab-scroll"
 import { FileTabContent } from "@/pages/session/file-tabs"
 import { FileTabContent } from "@/pages/session/file-tabs"
-import { createOpenSessionFileTab, getTabReorderIndex } from "@/pages/session/helpers"
+import { createOpenSessionFileTab, getTabReorderIndex, type Sizing } from "@/pages/session/helpers"
 import { StickyAddButton } from "@/pages/session/review-tab"
 import { StickyAddButton } from "@/pages/session/review-tab"
 import { setSessionHandoff } from "@/pages/session/handoff"
 import { setSessionHandoff } from "@/pages/session/handoff"
 
 
@@ -31,6 +31,7 @@ export function SessionSidePanel(props: {
   reviewPanel: () => JSX.Element
   reviewPanel: () => JSX.Element
   activeDiff?: string
   activeDiff?: string
   focusReviewDiff: (path: string) => void
   focusReviewDiff: (path: string) => void
+  size: Sizing
 }) {
 }) {
   const params = useParams()
   const params = useParams()
   const layout = useLayout()
   const layout = useLayout()
@@ -46,8 +47,15 @@ export function SessionSidePanel(props: {
   const view = createMemo(() => layout.view(sessionKey))
   const view = createMemo(() => layout.view(sessionKey))
 
 
   const reviewOpen = createMemo(() => isDesktop() && view().reviewPanel.opened())
   const reviewOpen = createMemo(() => isDesktop() && view().reviewPanel.opened())
-  const open = createMemo(() => isDesktop() && (view().reviewPanel.opened() || layout.fileTree.opened()))
+  const fileOpen = createMemo(() => isDesktop() && layout.fileTree.opened())
+  const open = createMemo(() => reviewOpen() || fileOpen())
   const reviewTab = createMemo(() => isDesktop())
   const reviewTab = createMemo(() => isDesktop())
+  const panelWidth = createMemo(() => {
+    if (!open()) return "0px"
+    if (reviewOpen()) return `calc(100% - ${layout.session.width()}px)`
+    return `${layout.fileTree.width()}px`
+  })
+  const treeWidth = createMemo(() => (fileOpen() ? `${layout.fileTree.width()}px` : "0px"))
 
 
   const info = createMemo(() => (params.id ? sync.session.get(params.id) : undefined))
   const info = createMemo(() => (params.id ? sync.session.get(params.id) : undefined))
   const diffs = createMemo(() => (params.id ? (sync.data.session_diff[params.id] ?? []) : []))
   const diffs = createMemo(() => (params.id ? (sync.data.session_diff[params.id] ?? []) : []))
@@ -60,6 +68,12 @@ export function SessionSidePanel(props: {
     return sync.data.session_diff[id] !== undefined
     return sync.data.session_diff[id] !== undefined
   })
   })
 
 
+  const reviewEmptyKey = createMemo(() => {
+    if (sync.project && !sync.project.vcs) return "session.review.noVcs"
+    if (sync.data.config.snapshot === false) return "session.review.noSnapshot"
+    return "session.review.noChanges"
+  })
+
   const diffFiles = createMemo(() => diffs().map((d) => d.file))
   const diffFiles = createMemo(() => diffs().map((d) => d.file))
   const kinds = createMemo(() => {
   const kinds = createMemo(() => {
     const merge = (a: "add" | "del" | "mix" | undefined, b: "add" | "del" | "mix") => {
     const merge = (a: "add" | "del" | "mix" | undefined, b: "add" | "del" | "mix") => {
@@ -87,6 +101,21 @@ export function SessionSidePanel(props: {
     return out
     return out
   })
   })
 
 
+  const empty = (msg: string) => (
+    <div class="h-full flex flex-col">
+      <div class="h-6 shrink-0" aria-hidden />
+      <div class="flex-1 pb-64 flex items-center justify-center text-center">
+        <div class="text-12-regular text-text-weak">{msg}</div>
+      </div>
+    </div>
+  )
+
+  const nofiles = createMemo(() => {
+    const state = file.tree.state("")
+    if (!state?.loaded) return false
+    return file.tree.children("").length === 0
+  })
+
   const normalizeTab = (tab: string) => {
   const normalizeTab = (tab: string) => {
     if (!tab.startsWith("file://")) return tab
     if (!tab.startsWith("file://")) return tab
     return file.tab(tab)
     return file.tab(tab)
@@ -145,17 +174,8 @@ export function SessionSidePanel(props: {
 
 
   const [store, setStore] = createStore({
   const [store, setStore] = createStore({
     activeDraggable: undefined as string | undefined,
     activeDraggable: undefined as string | undefined,
-    fileTreeScrolled: false,
   })
   })
 
 
-  let changesEl: HTMLDivElement | undefined
-  let allEl: HTMLDivElement | undefined
-
-  const syncFileTreeScrolled = (el?: HTMLDivElement) => {
-    const next = (el?.scrollTop ?? 0) > 0
-    setStore("fileTreeScrolled", (current) => (current === next ? current : next))
-  }
-
   const handleDragStart = (event: unknown) => {
   const handleDragStart = (event: unknown) => {
     const id = getDraggableId(event)
     const id = getDraggableId(event)
     if (!id) return
     if (!id) return
@@ -176,11 +196,6 @@ export function SessionSidePanel(props: {
     setStore("activeDraggable", undefined)
     setStore("activeDraggable", undefined)
   }
   }
 
 
-  createEffect(() => {
-    if (!layout.fileTree.opened()) return
-    syncFileTreeScrolled(fileTreeTab() === "changes" ? changesEl : allEl)
-  })
-
   createEffect(() => {
   createEffect(() => {
     if (!file.ready()) return
     if (!file.ready()) return
 
 
@@ -203,151 +218,172 @@ export function SessionSidePanel(props: {
   })
   })
 
 
   return (
   return (
-    <Show when={open()}>
+    <Show when={isDesktop()}>
       <aside
       <aside
         id="review-panel"
         id="review-panel"
         aria-label={language.t("session.panel.reviewAndFiles")}
         aria-label={language.t("session.panel.reviewAndFiles")}
-        class="relative min-w-0 h-full border-l border-border-weak-base flex"
+        aria-hidden={!open()}
+        inert={!open()}
+        class="relative min-w-0 h-full flex shrink-0 overflow-hidden bg-background-base"
         classList={{
         classList={{
-          "flex-1": reviewOpen(),
-          "shrink-0": !reviewOpen(),
+          "pointer-events-none": !open(),
+          "transition-[width] duration-[240ms] ease-[cubic-bezier(0.22,1,0.36,1)] will-change-[width] motion-reduce:transition-none":
+            !props.size.active(),
         }}
         }}
-        style={{ width: reviewOpen() ? undefined : `${layout.fileTree.width()}px` }}
+        style={{ width: panelWidth() }}
       >
       >
-        <Show when={reviewOpen()}>
-          <div class="flex-1 min-w-0 h-full">
-            <DragDropProvider
-              onDragStart={handleDragStart}
-              onDragEnd={handleDragEnd}
-              onDragOver={handleDragOver}
-              collisionDetector={closestCenter}
-            >
-              <DragDropSensors />
-              <ConstrainDragYAxis />
-              <Tabs value={activeTab()} onChange={openTab}>
-                <div class="sticky top-0 shrink-0 flex">
-                  <Tabs.List
-                    ref={(el: HTMLDivElement) => {
-                      const stop = createFileTabListSync({ el, contextOpen })
-                      onCleanup(stop)
-                    }}
-                  >
-                    <Show when={reviewTab()}>
-                      <Tabs.Trigger value="review">
-                        <div class="flex items-center gap-1.5">
-                          <div>{language.t("session.tab.review")}</div>
-                          <Show when={hasReview()}>
-                            <div>{reviewCount()}</div>
-                          </Show>
-                        </div>
-                      </Tabs.Trigger>
-                    </Show>
-                    <Show when={contextOpen()}>
-                      <Tabs.Trigger
-                        value="context"
-                        closeButton={
-                          <TooltipKeybind
-                            title={language.t("common.closeTab")}
-                            keybind={command.keybind("tab.close")}
-                            placement="bottom"
-                            gutter={10}
-                          >
-                            <IconButton
-                              icon="close-small"
-                              variant="ghost"
-                              class="h-5 w-5"
-                              onClick={() => tabs().close("context")}
-                              aria-label={language.t("common.closeTab")}
-                            />
-                          </TooltipKeybind>
-                        }
-                        hideCloseButton
-                        onMiddleClick={() => tabs().close("context")}
-                      >
-                        <div class="flex items-center gap-2">
-                          <SessionContextUsage variant="indicator" />
-                          <div>{language.t("session.tab.context")}</div>
-                        </div>
-                      </Tabs.Trigger>
-                    </Show>
-                    <SortableProvider ids={openedTabs()}>
-                      <For each={openedTabs()}>{(tab) => <SortableTab tab={tab} onTabClose={tabs().close} />}</For>
-                    </SortableProvider>
-                    <StickyAddButton>
-                      <TooltipKeybind
-                        title={language.t("command.file.open")}
-                        keybind={command.keybind("file.open")}
-                        class="flex items-center"
-                      >
-                        <IconButton
-                          icon="plus-small"
-                          variant="ghost"
-                          iconSize="large"
-                          class="!rounded-md"
-                          onClick={() => dialog.show(() => <DialogSelectFile mode="files" onOpenFile={showAllFiles} />)}
-                          aria-label={language.t("command.file.open")}
-                        />
-                      </TooltipKeybind>
-                    </StickyAddButton>
-                  </Tabs.List>
-                </div>
-
-                <Show when={reviewTab()}>
-                  <Tabs.Content value="review" class="flex flex-col h-full overflow-hidden contain-strict">
-                    <Show when={activeTab() === "review"}>{props.reviewPanel()}</Show>
-                  </Tabs.Content>
-                </Show>
-
-                <Tabs.Content value="empty" class="flex flex-col h-full overflow-hidden contain-strict">
-                  <Show when={activeTab() === "empty"}>
-                    <div class="relative pt-2 flex-1 min-h-0 overflow-hidden">
-                      <div class="h-full px-6 pb-42 flex flex-col items-center justify-center text-center gap-6">
-                        <Mark class="w-14 opacity-10" />
-                        <div class="text-14-regular text-text-weak max-w-56">
-                          {language.t("session.files.selectToOpen")}
-                        </div>
-                      </div>
-                    </div>
+        <div class="size-full flex border-l border-border-weaker-base">
+          <div
+            aria-hidden={!reviewOpen()}
+            inert={!reviewOpen()}
+            class="relative min-w-0 h-full flex-1 overflow-hidden bg-background-base"
+            classList={{
+              "pointer-events-none": !reviewOpen(),
+            }}
+          >
+            <div class="size-full min-w-0 h-full bg-background-base">
+              <DragDropProvider
+                onDragStart={handleDragStart}
+                onDragEnd={handleDragEnd}
+                onDragOver={handleDragOver}
+                collisionDetector={closestCenter}
+              >
+                <DragDropSensors />
+                <ConstrainDragYAxis />
+                <Tabs value={activeTab()} onChange={openTab}>
+                  <div class="sticky top-0 shrink-0 flex">
+                    <Tabs.List
+                      ref={(el: HTMLDivElement) => {
+                        const stop = createFileTabListSync({ el, contextOpen })
+                        onCleanup(stop)
+                      }}
+                    >
+                      <Show when={reviewTab()}>
+                        <Tabs.Trigger value="review">
+                          <div class="flex items-center gap-1.5">
+                            <div>{language.t("session.tab.review")}</div>
+                            <Show when={hasReview()}>
+                              <div>{reviewCount()}</div>
+                            </Show>
+                          </div>
+                        </Tabs.Trigger>
+                      </Show>
+                      <Show when={contextOpen()}>
+                        <Tabs.Trigger
+                          value="context"
+                          closeButton={
+                            <TooltipKeybind
+                              title={language.t("common.closeTab")}
+                              keybind={command.keybind("tab.close")}
+                              placement="bottom"
+                              gutter={10}
+                            >
+                              <IconButton
+                                icon="close-small"
+                                variant="ghost"
+                                class="h-5 w-5"
+                                onClick={() => tabs().close("context")}
+                                aria-label={language.t("common.closeTab")}
+                              />
+                            </TooltipKeybind>
+                          }
+                          hideCloseButton
+                          onMiddleClick={() => tabs().close("context")}
+                        >
+                          <div class="flex items-center gap-2">
+                            <SessionContextUsage variant="indicator" />
+                            <div>{language.t("session.tab.context")}</div>
+                          </div>
+                        </Tabs.Trigger>
+                      </Show>
+                      <SortableProvider ids={openedTabs()}>
+                        <For each={openedTabs()}>{(tab) => <SortableTab tab={tab} onTabClose={tabs().close} />}</For>
+                      </SortableProvider>
+                      <StickyAddButton>
+                        <TooltipKeybind
+                          title={language.t("command.file.open")}
+                          keybind={command.keybind("file.open")}
+                          class="flex items-center"
+                        >
+                          <IconButton
+                            icon="plus-small"
+                            variant="ghost"
+                            iconSize="large"
+                            class="!rounded-md"
+                            onClick={() =>
+                              dialog.show(() => <DialogSelectFile mode="files" onOpenFile={showAllFiles} />)
+                            }
+                            aria-label={language.t("command.file.open")}
+                          />
+                        </TooltipKeybind>
+                      </StickyAddButton>
+                    </Tabs.List>
+                  </div>
+
+                  <Show when={reviewTab()}>
+                    <Tabs.Content value="review" class="flex flex-col h-full overflow-hidden contain-strict">
+                      <Show when={activeTab() === "review"}>{props.reviewPanel()}</Show>
+                    </Tabs.Content>
                   </Show>
                   </Show>
-                </Tabs.Content>
 
 
-                <Show when={contextOpen()}>
-                  <Tabs.Content value="context" class="flex flex-col h-full overflow-hidden contain-strict">
-                    <Show when={activeTab() === "context"}>
+                  <Tabs.Content value="empty" class="flex flex-col h-full overflow-hidden contain-strict">
+                    <Show when={activeTab() === "empty"}>
                       <div class="relative pt-2 flex-1 min-h-0 overflow-hidden">
                       <div class="relative pt-2 flex-1 min-h-0 overflow-hidden">
-                        <SessionContextTab />
+                        <div class="h-full px-6 pb-42 -mt-4 flex flex-col items-center justify-center text-center gap-6">
+                          <Mark class="w-14 opacity-10" />
+                          <div class="text-14-regular text-text-weak max-w-56">
+                            {language.t("session.files.selectToOpen")}
+                          </div>
+                        </div>
                       </div>
                       </div>
                     </Show>
                     </Show>
                   </Tabs.Content>
                   </Tabs.Content>
-                </Show>
 
 
-                <Show when={activeFileTab()} keyed>
-                  {(tab) => <FileTabContent tab={tab} />}
-                </Show>
-              </Tabs>
-              <DragOverlay>
-                <Show when={store.activeDraggable} keyed>
-                  {(tab) => {
-                    const path = createMemo(() => file.pathFromTab(tab))
-                    return (
-                      <div data-component="tabs-drag-preview">
-                        <Show when={path()} keyed>
-                          {(p) => <FileVisual active path={p} />}
-                        </Show>
-                      </div>
-                    )
-                  }}
-                </Show>
-              </DragOverlay>
-            </DragDropProvider>
+                  <Show when={contextOpen()}>
+                    <Tabs.Content value="context" class="flex flex-col h-full overflow-hidden contain-strict">
+                      <Show when={activeTab() === "context"}>
+                        <div class="relative pt-2 flex-1 min-h-0 overflow-hidden">
+                          <SessionContextTab />
+                        </div>
+                      </Show>
+                    </Tabs.Content>
+                  </Show>
+
+                  <Show when={activeFileTab()} keyed>
+                    {(tab) => <FileTabContent tab={tab} />}
+                  </Show>
+                </Tabs>
+                <DragOverlay>
+                  <Show when={store.activeDraggable} keyed>
+                    {(tab) => {
+                      const path = createMemo(() => file.pathFromTab(tab))
+                      return (
+                        <div data-component="tabs-drag-preview">
+                          <Show when={path()}>{(p) => <FileVisual active path={p()} />}</Show>
+                        </div>
+                      )
+                    }}
+                  </Show>
+                </DragOverlay>
+              </DragDropProvider>
+            </div>
           </div>
           </div>
-        </Show>
 
 
-        <Show when={layout.fileTree.opened()}>
-          <div id="file-tree-panel" class="relative shrink-0 h-full" style={{ width: `${layout.fileTree.width()}px` }}>
+          <div
+            id="file-tree-panel"
+            aria-hidden={!fileOpen()}
+            inert={!fileOpen()}
+            class="relative min-w-0 h-full shrink-0 overflow-hidden"
+            classList={{
+              "pointer-events-none": !fileOpen(),
+              "transition-[width] duration-200 ease-[cubic-bezier(0.22,1,0.36,1)] will-change-[width] motion-reduce:transition-none":
+                !props.size.active(),
+            }}
+            style={{ width: treeWidth() }}
+          >
             <div
             <div
               class="h-full flex flex-col overflow-hidden group/filetree"
               class="h-full flex flex-col overflow-hidden group/filetree"
-              classList={{ "border-l border-border-weak-base": reviewOpen() }}
+              classList={{ "border-l border-border-weaker-base": reviewOpen() }}
             >
             >
               <Tabs
               <Tabs
                 variant="pill"
                 variant="pill"
@@ -356,7 +392,7 @@ export function SessionSidePanel(props: {
                 class="h-full"
                 class="h-full"
                 data-scope="filetree"
                 data-scope="filetree"
               >
               >
-                <Tabs.List data-scrolled={store.fileTreeScrolled ? "" : undefined}>
+                <Tabs.List>
                   <Tabs.Trigger value="changes" class="flex-1" classes={{ button: "w-full" }}>
                   <Tabs.Trigger value="changes" class="flex-1" classes={{ button: "w-full" }}>
                     {reviewCount()}{" "}
                     {reviewCount()}{" "}
                     {language.t(reviewCount() === 1 ? "session.review.change.one" : "session.review.change.other")}
                     {language.t(reviewCount() === 1 ? "session.review.change.one" : "session.review.change.other")}
@@ -365,12 +401,7 @@ export function SessionSidePanel(props: {
                     {language.t("session.files.all")}
                     {language.t("session.files.all")}
                   </Tabs.Trigger>
                   </Tabs.Trigger>
                 </Tabs.List>
                 </Tabs.List>
-                <Tabs.Content
-                  value="changes"
-                  ref={(el: HTMLDivElement) => (changesEl = el)}
-                  onScroll={(e: UIEvent & { currentTarget: HTMLDivElement }) => syncFileTreeScrolled(e.currentTarget)}
-                  class="bg-background-stronger px-3 py-0"
-                >
+                <Tabs.Content value="changes" class="bg-background-stronger px-3 py-0">
                   <Switch>
                   <Switch>
                     <Match when={hasReview()}>
                     <Match when={hasReview()}>
                       <Show
                       <Show
@@ -384,6 +415,7 @@ export function SessionSidePanel(props: {
                       >
                       >
                         <FileTree
                         <FileTree
                           path=""
                           path=""
+                          class="pt-3"
                           allowed={diffFiles()}
                           allowed={diffFiles()}
                           kinds={kinds()}
                           kinds={kinds()}
                           draggable={false}
                           draggable={false}
@@ -393,39 +425,47 @@ export function SessionSidePanel(props: {
                       </Show>
                       </Show>
                     </Match>
                     </Match>
                     <Match when={true}>
                     <Match when={true}>
-                      <div class="mt-8 text-center text-12-regular text-text-weak">
-                        {language.t("session.review.noChanges")}
-                      </div>
+                      {empty(
+                        language.t(sync.project && !sync.project.vcs ? "session.review.noChanges" : reviewEmptyKey()),
+                      )}
                     </Match>
                     </Match>
                   </Switch>
                   </Switch>
                 </Tabs.Content>
                 </Tabs.Content>
-                <Tabs.Content
-                  value="all"
-                  ref={(el: HTMLDivElement) => (allEl = el)}
-                  onScroll={(e: UIEvent & { currentTarget: HTMLDivElement }) => syncFileTreeScrolled(e.currentTarget)}
-                  class="bg-background-stronger px-3 py-0"
-                >
-                  <FileTree
-                    path=""
-                    modified={diffFiles()}
-                    kinds={kinds()}
-                    onFileClick={(node) => openTab(file.tab(node.path))}
-                  />
+                <Tabs.Content value="all" class="bg-background-stronger px-3 py-0">
+                  <Switch>
+                    <Match when={nofiles()}>{empty(language.t("session.files.empty"))}</Match>
+                    <Match when={true}>
+                      <FileTree
+                        path=""
+                        class="pt-3"
+                        modified={diffFiles()}
+                        kinds={kinds()}
+                        onFileClick={(node) => openTab(file.tab(node.path))}
+                      />
+                    </Match>
+                  </Switch>
                 </Tabs.Content>
                 </Tabs.Content>
               </Tabs>
               </Tabs>
             </div>
             </div>
-            <ResizeHandle
-              direction="horizontal"
-              edge="start"
-              size={layout.fileTree.width()}
-              min={200}
-              max={480}
-              collapseThreshold={160}
-              onResize={layout.fileTree.resize}
-              onCollapse={layout.fileTree.close}
-            />
+            <Show when={fileOpen()}>
+              <div onPointerDown={() => props.size.start()}>
+                <ResizeHandle
+                  direction="horizontal"
+                  edge="start"
+                  size={layout.fileTree.width()}
+                  min={200}
+                  max={480}
+                  collapseThreshold={160}
+                  onResize={(width) => {
+                    props.size.touch()
+                    layout.fileTree.resize(width)
+                  }}
+                  onCollapse={layout.fileTree.close}
+                />
+              </div>
+            </Show>
           </div>
           </div>
-        </Show>
+        </div>
       </aside>
       </aside>
     </Show>
     </Show>
   )
   )

+ 522 - 0
packages/app/src/pages/session/session-timeline-header.tsx

@@ -0,0 +1,522 @@
+import { createEffect, createMemo, on, onCleanup, Show } from "solid-js"
+import { createStore, produce } from "solid-js/store"
+import { useNavigate, useParams } from "@solidjs/router"
+import { Button } from "@opencode-ai/ui/button"
+import { IconButton } from "@opencode-ai/ui/icon-button"
+import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu"
+import { Dialog } from "@opencode-ai/ui/dialog"
+import { prefersReducedMotion } from "@opencode-ai/ui/hooks"
+import { InlineInput } from "@opencode-ai/ui/inline-input"
+import { animate, type AnimationPlaybackControls, clearFadeStyles, FAST_SPRING } from "@opencode-ai/ui/motion"
+import { showToast } from "@opencode-ai/ui/toast"
+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"
+import { useSDK } from "@/context/sdk"
+import { useSync } from "@/context/sync"
+
+export function SessionTimelineHeader(props: {
+  centered: boolean
+  showHeader: () => boolean
+  sessionKey: () => string
+  sessionID: () => string | undefined
+  parentID: () => string | undefined
+  titleValue: () => string | undefined
+  headerTitle: () => string | undefined
+  placeholderTitle: () => boolean
+}) {
+  const navigate = useNavigate()
+  const params = useParams()
+  const sdk = useSDK()
+  const sync = useSync()
+  const dialog = useDialog()
+  const language = useLanguage()
+  const reduce = prefersReducedMotion
+
+  const [title, setTitle] = createStore({
+    draft: "",
+    editing: false,
+    saving: false,
+    menuOpen: false,
+    pendingRename: false,
+  })
+  const [headerText, setHeaderText] = createStore({
+    session: props.sessionKey(),
+    value: props.headerTitle(),
+    prev: undefined as string | undefined,
+    muted: props.placeholderTitle(),
+    prevMuted: false,
+  })
+  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 clearHeaderAnim = () => {
+    headerAnim?.stop()
+    headerAnim = undefined
+  }
+
+  const animateHeader = () => {
+    const el = headerRef
+    if (!el) return
+
+    clearHeaderAnim()
+    if (!headerText.muted || reduce()) {
+      el.style.opacity = "1"
+      return
+    }
+
+    headerAnim = animate(el, { opacity: [0, 1] }, { type: "spring", visualDuration: 1.0, bounce: 0 })
+    headerAnim.finished.then(() => {
+      if (headerRef !== el) return
+      clearFadeStyles(el)
+    })
+  }
+
+  const clearTitleAnims = () => {
+    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
+    if (reduce()) {
+      settleTitleEnter()
+      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()
+    setHeaderText({ prev: headerText.value, prevMuted: headerText.muted })
+    setHeaderText({ value: nextTitle, muted: nextMuted })
+
+    if (reduce()) {
+      setHeaderText({ prev: undefined, prevMuted: false })
+      hideLeave()
+      settleTitleEnter()
+      return
+    }
+
+    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(props.showHeader, (show, prev) => {
+      if (!show) {
+        clearHeaderAnim()
+        return
+      }
+      if (show === prev) return
+      animateHeader()
+    }),
+  )
+
+  createEffect(
+    on(
+      () => [props.sessionKey(), props.headerTitle(), props.placeholderTitle()] as const,
+      ([nextSession, nextTitle, nextMuted]) => {
+        if (nextSession !== headerText.session) {
+          setHeaderText("session", nextSession)
+          if (nextTitle && nextMuted) {
+            fadeInTitle(nextTitle, nextMuted)
+            return
+          }
+          snapTitle(nextTitle, nextMuted)
+          return
+        }
+        if (nextTitle === headerText.value && nextMuted === headerText.muted) return
+        if (!nextTitle) {
+          snapTitle(undefined, false)
+          return
+        }
+        if (!headerText.value) {
+          fadeInTitle(nextTitle, nextMuted)
+          return
+        }
+        if (title.saving || title.editing) {
+          snapTitle(nextTitle, nextMuted)
+          return
+        }
+        crossfadeTitle(nextTitle, nextMuted)
+      },
+    ),
+  )
+
+  onCleanup(() => {
+    clearHeaderAnim()
+    clearTitleAnims()
+  })
+
+  const toastError = (err: unknown) => errorMessage(err, language.t("common.requestFailed"))
+
+  createEffect(
+    on(
+      props.sessionKey,
+      () => setTitle({ draft: "", editing: false, saving: false, menuOpen: false, pendingRename: false }),
+      { defer: true },
+    ),
+  )
+
+  const openTitleEditor = () => {
+    if (!props.sessionID()) return
+    setTitle({ editing: true, draft: props.titleValue() ?? "" })
+    requestAnimationFrame(() => {
+      titleRef?.focus()
+      titleRef?.select()
+    })
+  }
+
+  const closeTitleEditor = () => {
+    if (title.saving) return
+    setTitle({ editing: false, saving: false })
+  }
+
+  const saveTitleEditor = async () => {
+    const id = props.sessionID()
+    if (!id) return
+    if (title.saving) return
+
+    const next = title.draft.trim()
+    if (!next || next === (props.titleValue() ?? "")) {
+      setTitle({ editing: false, saving: false })
+      return
+    }
+
+    setTitle("saving", true)
+    await sdk.client.session
+      .update({ sessionID: id, title: next })
+      .then(() => {
+        sync.set(
+          produce((draft) => {
+            const index = draft.session.findIndex((session) => session.id === id)
+            if (index !== -1) draft.session[index].title = next
+          }),
+        )
+        setTitle({ editing: false, saving: false })
+      })
+      .catch((err) => {
+        setTitle("saving", false)
+        showToast({
+          title: language.t("common.requestFailed"),
+          description: toastError(err),
+        })
+      })
+  }
+
+  const navigateAfterSessionRemoval = (sessionID: string, parentID?: string, nextSessionID?: string) => {
+    if (params.id !== sessionID) return
+    if (parentID) {
+      navigate(`/${params.dir}/session/${parentID}`)
+      return
+    }
+    if (nextSessionID) {
+      navigate(`/${params.dir}/session/${nextSessionID}`)
+      return
+    }
+    navigate(`/${params.dir}/session`)
+  }
+
+  const archiveSession = async (sessionID: string) => {
+    const session = sync.session.get(sessionID)
+    if (!session) return
+
+    const sessions = sync.data.session ?? []
+    const index = sessions.findIndex((item) => item.id === sessionID)
+    const nextSession = index === -1 ? undefined : (sessions[index + 1] ?? sessions[index - 1])
+
+    await sdk.client.session
+      .update({ sessionID, time: { archived: Date.now() } })
+      .then(() => {
+        sync.set(
+          produce((draft) => {
+            const index = draft.session.findIndex((item) => item.id === sessionID)
+            if (index !== -1) draft.session.splice(index, 1)
+          }),
+        )
+        navigateAfterSessionRemoval(sessionID, session.parentID, nextSession?.id)
+      })
+      .catch((err) => {
+        showToast({
+          title: language.t("common.requestFailed"),
+          description: toastError(err),
+        })
+      })
+  }
+
+  const deleteSession = async (sessionID: string) => {
+    const session = sync.session.get(sessionID)
+    if (!session) return false
+
+    const sessions = (sync.data.session ?? []).filter((item) => !item.parentID && !item.time?.archived)
+    const index = sessions.findIndex((item) => item.id === sessionID)
+    const nextSession = index === -1 ? undefined : (sessions[index + 1] ?? sessions[index - 1])
+
+    const result = await sdk.client.session
+      .delete({ sessionID })
+      .then((x) => x.data)
+      .catch((err) => {
+        showToast({
+          title: language.t("session.delete.failed.title"),
+          description: toastError(err),
+        })
+        return false
+      })
+
+    if (!result) return false
+
+    sync.set(
+      produce((draft) => {
+        const removed = new Set<string>([sessionID])
+        const byParent = new Map<string, string[]>()
+
+        for (const item of draft.session) {
+          const parentID = item.parentID
+          if (!parentID) continue
+
+          const existing = byParent.get(parentID)
+          if (existing) {
+            existing.push(item.id)
+            continue
+          }
+          byParent.set(parentID, [item.id])
+        }
+
+        const stack = [sessionID]
+        while (stack.length) {
+          const parentID = stack.pop()
+          if (!parentID) continue
+
+          const children = byParent.get(parentID)
+          if (!children) continue
+
+          for (const child of children) {
+            if (removed.has(child)) continue
+            removed.add(child)
+            stack.push(child)
+          }
+        }
+
+        draft.session = draft.session.filter((item) => !removed.has(item.id))
+      }),
+    )
+
+    navigateAfterSessionRemoval(sessionID, session.parentID, nextSession?.id)
+    return true
+  }
+
+  const navigateParent = () => {
+    const id = props.parentID()
+    if (!id) return
+    navigate(`/${params.dir}/session/${id}`)
+  }
+
+  function DialogDeleteSession(input: { sessionID: string }) {
+    const name = createMemo(() => sync.session.get(input.sessionID)?.title ?? language.t("command.session.new"))
+
+    const handleDelete = async () => {
+      await deleteSession(input.sessionID)
+      dialog.close()
+    }
+
+    return (
+      <Dialog title={language.t("session.delete.title")} fit>
+        <div class="flex flex-col gap-4 pl-6 pr-2.5 pb-3">
+          <div class="flex flex-col gap-1">
+            <span class="text-14-regular text-text-strong">
+              {language.t("session.delete.confirm", { name: name() })}
+            </span>
+          </div>
+          <div class="flex justify-end gap-2">
+            <Button variant="ghost" size="large" onClick={() => dialog.close()}>
+              {language.t("common.cancel")}
+            </Button>
+            <Button variant="primary" size="large" onClick={handleDelete}>
+              {language.t("session.delete.button")}
+            </Button>
+          </div>
+        </div>
+      </Dialog>
+    )
+  }
+
+  return (
+    <Show when={props.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
+          classList={{
+            "bg-[linear-gradient(to_bottom,var(--background-stronger)_38px,transparent)]": true,
+            "w-full": true,
+            "pb-10": true,
+            "px-4 md:px-5": true,
+            "md:max-w-[500px] md:mx-auto 2xl:max-w-[700px]": props.centered,
+          }}
+        >
+          <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">
+              <Show when={props.parentID()}>
+                <div>
+                  <IconButton
+                    tabIndex={-1}
+                    icon="arrow-left"
+                    variant="ghost"
+                    onClick={navigateParent}
+                    aria-label={language.t("common.goBack")}
+                  />
+                </div>
+              </Show>
+              <Show when={!!headerText.value || title.editing}>
+                <Show
+                  when={title.editing}
+                  fallback={
+                    <h1 class="text-14-medium text-text-strong grow-1 min-w-0" 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>
+                  }
+                >
+                  <InlineInput
+                    ref={(el) => {
+                      titleRef = el
+                    }}
+                    value={title.draft}
+                    disabled={title.saving}
+                    class="text-14-medium text-text-strong grow-1 min-w-0 rounded-[6px]"
+                    style={{ "--inline-input-shadow": "var(--shadow-xs-border-select)" }}
+                    onInput={(event) => setTitle("draft", event.currentTarget.value)}
+                    onKeyDown={(event) => {
+                      event.stopPropagation()
+                      if (event.key === "Enter") {
+                        event.preventDefault()
+                        void saveTitleEditor()
+                        return
+                      }
+                      if (event.key === "Escape") {
+                        event.preventDefault()
+                        closeTitleEditor()
+                      }
+                    }}
+                    onBlur={closeTitleEditor}
+                  />
+                </Show>
+              </Show>
+            </div>
+            <Show when={props.sessionID()}>
+              {(id) => (
+                <div class="shrink-0 flex items-center gap-3">
+                  <SessionContextUsage placement="bottom" />
+                  <DropdownMenu
+                    gutter={4}
+                    placement="bottom-end"
+                    open={title.menuOpen}
+                    onOpenChange={(open) => setTitle("menuOpen", open)}
+                  >
+                    <DropdownMenu.Trigger
+                      as={IconButton}
+                      icon="dot-grid"
+                      variant="ghost"
+                      class="size-6 rounded-md data-[expanded]:bg-surface-base-active"
+                      aria-label={language.t("common.moreOptions")}
+                    />
+                    <DropdownMenu.Portal>
+                      <DropdownMenu.Content
+                        style={{ "min-width": "104px" }}
+                        onCloseAutoFocus={(event) => {
+                          if (!title.pendingRename) return
+                          event.preventDefault()
+                          setTitle("pendingRename", false)
+                          openTitleEditor()
+                        }}
+                      >
+                        <DropdownMenu.Item
+                          onSelect={() => {
+                            setTitle("pendingRename", true)
+                            setTitle("menuOpen", false)
+                          }}
+                        >
+                          <DropdownMenu.ItemLabel>{language.t("common.rename")}</DropdownMenu.ItemLabel>
+                        </DropdownMenu.Item>
+                        <DropdownMenu.Item onSelect={() => void archiveSession(id())}>
+                          <DropdownMenu.ItemLabel>{language.t("common.archive")}</DropdownMenu.ItemLabel>
+                        </DropdownMenu.Item>
+                        <DropdownMenu.Separator />
+                        <DropdownMenu.Item onSelect={() => dialog.show(() => <DialogDeleteSession sessionID={id()} />)}>
+                          <DropdownMenu.ItemLabel>{language.t("common.delete")}</DropdownMenu.ItemLabel>
+                        </DropdownMenu.Item>
+                      </DropdownMenu.Content>
+                    </DropdownMenu.Portal>
+                  </DropdownMenu>
+                </div>
+              )}
+            </Show>
+          </div>
+        </div>
+      </div>
+    </Show>
+  )
+}

Энэ ялгаанд хэт олон файл өөрчлөгдсөн тул зарим файлыг харуулаагүй болно