dsh-ide-git 0.13.0 → 0.13.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dsh.plugin.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "id": "dsh-external/dsh-ide-git",
3
- "version": "0.13.0",
3
+ "version": "0.13.2",
4
4
  "main": "./src/index.js",
5
5
  "description": "IDE 级 Git 操作面板:分支树 + 提交图谱 + 变更/暂存/提交 + 提交详情。装了 dsh-better-sidebar 时挂成它的原生 tab(右侧栏 + 底部工作台);没装时直接挂 DSH 原生右侧栏,单独安装即可用。JetBrains 风格 Git 工具窗口 for DeepSeek Harness.",
6
6
  "engines": {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-ide-git",
3
- "version": "0.13.0",
3
+ "version": "0.13.2",
4
4
  "description": "DSH plugin: an IDE-grade Git tool window as a native dsh-better-sidebar tab — branch tree, commit graph, changes, commit details, JetBrains-style actions, in the right sidebar and the bottom panel. | DSH 插件:IDE 级 Git 工具窗口,以 dsh-better-sidebar 原生 Tab 挂载——分支树 / 提交图谱 / 变更与提交详情 / JetBrains 风格操作,右侧栏与底部面板双布局自适应。",
5
5
  "type": "module",
6
6
  "repository": {
package/src/client.js CHANGED
@@ -27,6 +27,13 @@ window.__ModuleLoader__.load({
27
27
  const useRef = React.useRef
28
28
  const useCallback = React.useCallback
29
29
  const useLayoutEffect = React.useLayoutEffect
30
+ const memo = React.memo
31
+ /* Measure-and-derive effects run here for the layout timing (the chrome and
32
+ the rail capacity derive from these numbers — a paint-then-correct cycle
33
+ read as buttons lighting up one by one on every seat switch). Environments
34
+ without a frame loop (the SSR self-check's shims) get the plain effect —
35
+ there is no paint to sync with there, and no server warning either. */
36
+ const useMeasureLayoutEffect = typeof requestAnimationFrame === 'function' ? useLayoutEffect : useEffect
30
37
 
31
38
  /* API base is MOUNT-RELATIVE (no leading slash). dsh 0.1.7 serves the
32
39
  shell with <base href="./">, so document.baseURI is the mount the page
@@ -4233,8 +4240,86 @@ window.__ModuleLoader__.load({
4233
4240
  return body.data
4234
4241
  }
4235
4242
 
4243
+ /* ============================== first-paint cache ============================== */
4244
+
4245
+ /* Session-scoped seats (the conversation view, and every sidebar seat that
4246
+ follows the active session) REMOUNT their panel on each session switch:
4247
+ the slot subtree belongs to the session. Re-running the whole discovery
4248
+ (repos scan) + first page (summary / branches / log — three git spawns)
4249
+ on every switch made the chrome re-appear piece by piece (reported
4250
+ against v0.13.0). Same workspace ⇒ same repository ⇒ the previous paint
4251
+ is still essentially correct, so it seeds the states and a background
4252
+ refresh lands the fresh data a moment later. Pure functions; the panel
4253
+ only ever reads them at effect time and writes after a fetch. */
4254
+ const PAINT_TTL_MS = 30_000
4255
+ const paintRepos = new Map() /* cwd -> { at, data } */
4256
+ const paintPage = new Map() /* repoRoot -> { at, summary, branches, commits, hasMore } */
4257
+ const paintRoot = new Map() /* cwd -> last repoRoot the user was looking at */
4258
+
4259
+ function readPaintRepos(cwd) {
4260
+ const entry = paintRepos.get(cwd)
4261
+ if (entry === undefined || Date.now() - entry.at > PAINT_TTL_MS) return undefined
4262
+ return entry.data
4263
+ }
4264
+
4265
+ function writePaintRepos(cwd, data) {
4266
+ paintRepos.set(cwd, { at: Date.now(), data: data })
4267
+ if (paintRepos.size > 16) paintRepos.delete(paintRepos.keys().next().value)
4268
+ }
4269
+
4270
+ function readPaintPage(repoRoot) {
4271
+ const entry = paintPage.get(repoRoot)
4272
+ if (entry === undefined || Date.now() - entry.at > PAINT_TTL_MS) return undefined
4273
+ return entry
4274
+ }
4275
+
4276
+ function writePaintPage(repoRoot, page) {
4277
+ paintPage.set(repoRoot, Object.assign({ at: Date.now() }, page))
4278
+ if (paintPage.size > 16) paintPage.delete(paintPage.keys().next().value)
4279
+ }
4280
+
4281
+ function rememberPaintRoot(cwd, repoRoot) {
4282
+ if (typeof cwd === 'string' && cwd !== '' && repoRoot !== null) paintRoot.set(cwd, repoRoot)
4283
+ }
4284
+
4285
+ function readPaintRoot(cwd) {
4286
+ return paintRoot.get(cwd)
4287
+ }
4288
+
4236
4289
  /* ============================== storage ============================== */
4237
4290
 
4291
+ /* ---- tree UI state (v0.13.1): the session-scoped seats remount on every
4292
+ session switch, and component state died with them — branch sections
4293
+ snapped back to defaults and folders re-folded. One localStorage home,
4294
+ GLOBAL by design: which sections you keep folded is a habit, not a
4295
+ property of the repository. ---- */
4296
+ const TREE_KEY = 'dsh-ide-git.tree.v1'
4297
+
4298
+ function readTreeState() {
4299
+ try {
4300
+ const raw = window.localStorage.getItem(TREE_KEY)
4301
+ if (raw === null || raw === '') return null
4302
+ const parsed = JSON.parse(raw)
4303
+ return parsed !== null && typeof parsed === 'object' ? parsed : null
4304
+ } catch (error) { void error }
4305
+ return null
4306
+ }
4307
+
4308
+ function writeTreeState(state) {
4309
+ try { window.localStorage.setItem(TREE_KEY, JSON.stringify(state)) } catch (error) { void error }
4310
+ }
4311
+
4312
+ function treeSectionDefaults() {
4313
+ return { favorites: false, remote: false, tags: true }
4314
+ }
4315
+
4316
+ function treeSectionsOf(stored) {
4317
+ const sections = stored !== null && typeof stored === 'object' && stored.sections !== null && typeof stored.sections === 'object'
4318
+ ? stored.sections
4319
+ : {}
4320
+ return Object.assign(treeSectionDefaults(), sections)
4321
+ }
4322
+
4238
4323
  /* ---- ai commit pickers (issue #7): the model catalog and per-model
4239
4324
  reasoning efforts, served by the host's commit-models / commit-efforts
4240
4325
  routes. Cached for a short window so the settings card can mount twice
@@ -5975,9 +6060,23 @@ window.__ModuleLoader__.load({
5975
6060
  function BranchTree(props) {
5976
6061
  const t = props.t
5977
6062
  const [filter, setFilter] = useState('')
5978
- const [collapsed, setCollapsed] = useState({ favorites: false, remote: false, tags: true })
6063
+ const [collapsed, setCollapsed] = useState(() => treeSectionsOf(readTreeState()))
5979
6064
  const [picked, setPicked] = useState('')
5980
- const [foldedFolders, setFoldedFolders] = useState({})
6065
+ const [foldedFolders, setFoldedFolders] = useState(() => {
6066
+ const stored = readTreeState()
6067
+ return stored !== null && typeof stored === 'object' && stored.folders !== null && typeof stored.folders === 'object'
6068
+ ? stored.folders
6069
+ : {}
6070
+ })
6071
+ /* Survive the remount a session switch performs (v0.13.1). */
6072
+ useEffect(() => {
6073
+ const stored = readTreeState()
6074
+ writeTreeState(Object.assign({}, stored, { sections: collapsed }))
6075
+ }, [collapsed])
6076
+ useEffect(() => {
6077
+ const stored = readTreeState()
6078
+ writeTreeState(Object.assign({}, stored, { folders: foldedFolders }))
6079
+ }, [foldedFolders])
5981
6080
  const branches = props.branches
5982
6081
  const needle = filter.trim().toLowerCase()
5983
6082
  const match = (name) => needle === '' || String(name).toLowerCase().indexOf(needle) >= 0
@@ -6093,7 +6192,15 @@ window.__ModuleLoader__.load({
6093
6192
  const summary = props.summary
6094
6193
  const [message, setMessage] = useState('')
6095
6194
  const [amend, setAmend] = useState(false)
6096
- const [collapsed, setCollapsed] = useState(false)
6195
+ const [collapsed, setCollapsed] = useState(() => {
6196
+ const stored = readTreeState()
6197
+ return stored !== null && typeof stored === 'object' && stored.changesCollapsed === true
6198
+ })
6199
+ /* Same remount-survival as the branch sections (v0.13.1). */
6200
+ useEffect(() => {
6201
+ const stored = readTreeState()
6202
+ writeTreeState(Object.assign({}, stored, { changesCollapsed: collapsed === true }))
6203
+ }, [collapsed])
6097
6204
  /* AI-written message (issue #6). The draft is kept so the write can be
6098
6205
  undone, and an existing draft is never overwritten without a word. */
6099
6206
  const [aiBusy, setAiBusy] = useState(false)
@@ -6573,7 +6680,12 @@ window.__ModuleLoader__.load({
6573
6680
  const sessionId = typeof scope.sessionId === 'string' ? scope.sessionId : 'default'
6574
6681
  const base = useMemo(() => ({ cwd: cwd, repoRoot: repoRoot === null ? undefined : repoRoot }), [cwd, repoRoot])
6575
6682
 
6576
- useEffect(() => {
6683
+ /* Layout effect ON PURPOSE: the chrome (compact/columns/stack) and the
6684
+ rail capacity derive from these numbers, and a paint-then-correct
6685
+ cycle showed up on every seat switch as buttons lighting up one by
6686
+ one (v0.13.1 report). Measuring in the commit phase keeps the first
6687
+ paint on the final chrome. */
6688
+ useMeasureLayoutEffect(() => {
6577
6689
  const element = hostRef.current
6578
6690
  if (element === null) return undefined
6579
6691
  const measure = () => {
@@ -6617,7 +6729,7 @@ window.__ModuleLoader__.load({
6617
6729
  // Like the rail, the panes measure the box that actually holds them instead
6618
6730
  // of trusting the root probe: the column chrome keeps a vertical rail
6619
6731
  // beside the body, and the header sits above it.
6620
- useEffect(() => {
6732
+ useMeasureLayoutEffect(() => {
6621
6733
  const element = bodyRef.current
6622
6734
  if (element === null) { setBodyBox({ width: 0, height: 0 }); return undefined }
6623
6735
  const measure = () => {
@@ -6755,7 +6867,9 @@ window.__ModuleLoader__.load({
6755
6867
  // function of the strip that holds them (a 1200x300 workbench and a 300x900
6756
6868
  // sidebar disagree about that). railBox keeps the last measured span; until
6757
6869
  // the first measurement lands, every action is rendered.
6758
- useEffect(() => {
6870
+ // Layout effect (see the host measure above): the button count derives
6871
+ // from this box, and a late measure reads as the rail lighting up late.
6872
+ useMeasureLayoutEffect(() => {
6759
6873
  const element = railHostRef.current
6760
6874
  if (element === null) { setRailBox({ width: 0, height: 0 }); return undefined }
6761
6875
  const measure = () => {
@@ -6798,18 +6912,30 @@ window.__ModuleLoader__.load({
6798
6912
  as "still loading" and would spin forever). */
6799
6913
  if (cwd === undefined) { setRepoState({ repos: [], isRepo: false }); return undefined }
6800
6914
  let cancelled = false
6915
+ /* First-paint cache: a session switch through the same workspace
6916
+ reuses the previous discovery synchronously (repo bar and pick land
6917
+ with the first paint), then the background scan re-proves it. */
6918
+ const apply = (data, cachedPick) => {
6919
+ setRepoState(data)
6920
+ const paths = data.repos.map((repo) => repo.path)
6921
+ const remembered = readRememberedRepo(sessionId)
6922
+ let pick = null
6923
+ if (remembered !== undefined && paths.indexOf(remembered) >= 0) pick = remembered
6924
+ else if (cachedPick !== undefined && paths.indexOf(cachedPick) >= 0) pick = cachedPick
6925
+ else if (data.isRepo === true) pick = data.cwd
6926
+ else if (paths.length > 0) pick = paths[0]
6927
+ setRepoRoot(pick)
6928
+ rememberPaintRoot(cwd, pick)
6929
+ return pick
6930
+ }
6931
+ const cached = readPaintRepos(cwd)
6932
+ if (cached !== undefined) apply(cached, readPaintRoot(cwd))
6801
6933
  void (async () => {
6802
6934
  try {
6803
6935
  const data = await request('repos', { cwd: cwd })
6804
6936
  if (cancelled) return
6805
- setRepoState(data)
6806
- const paths = data.repos.map((repo) => repo.path)
6807
- const remembered = readRememberedRepo(sessionId)
6808
- let pick = null
6809
- if (remembered !== undefined && paths.indexOf(remembered) >= 0) pick = remembered
6810
- else if (data.isRepo === true) pick = data.cwd
6811
- else if (paths.length > 0) pick = paths[0]
6812
- setRepoRoot(pick)
6937
+ writePaintRepos(cwd, data)
6938
+ apply(data, readPaintRoot(cwd))
6813
6939
  } catch (caught) {
6814
6940
  if (!cancelled) setError(caught instanceof Error ? caught.message : String(caught))
6815
6941
  }
@@ -6824,7 +6950,9 @@ window.__ModuleLoader__.load({
6824
6950
  }, [base, showIgnored])
6825
6951
 
6826
6952
  const loadBranches = useCallback(async () => {
6827
- setBranches(await request('branches', base))
6953
+ const data = await request('branches', base)
6954
+ setBranches(data)
6955
+ return data
6828
6956
  }, [base])
6829
6957
 
6830
6958
  const loadCommits = useCallback(async (skip, overridePath) => {
@@ -6834,19 +6962,39 @@ window.__ModuleLoader__.load({
6834
6962
  const data = await request('log', payload)
6835
6963
  setCommits((previous) => (skip === undefined || skip === 0 ? data.commits : previous.concat(data.commits)))
6836
6964
  setHasMore(data.hasMore)
6965
+ return data
6837
6966
  }, [base, pathFilter])
6838
6967
 
6839
6968
  const refresh = useCallback(async () => {
6840
6969
  if (repoRoot === null) return
6841
6970
  await guard(async () => {
6842
- await Promise.all([loadSummary(), loadBranches(), loadCommits(0)])
6971
+ const [summaryData, branchesData, page] = await Promise.all([loadSummary(), loadBranches(), loadCommits(0)])
6972
+ /* The write side of the first-paint cache: exactly the FIRST page
6973
+ (a paged fetch never reaches this path), replaced on every
6974
+ refresh — the auto-refresh included — so a session switch through
6975
+ the same workspace seeds fresh data, not the mount-time
6976
+ snapshot. */
6977
+ writePaintPage(repoRoot, { summary: summaryData, branches: branchesData, commits: page.commits, hasMore: page.hasMore })
6843
6978
  })
6844
6979
  }, [guard, loadSummary, loadBranches, loadCommits, repoRoot])
6845
6980
 
6846
6981
  useEffect(() => {
6847
6982
  if (repoRoot === null) return undefined
6983
+ /* First-paint cache: seed the first page synchronously so a session
6984
+ switch through the same workspace paints the full chrome (rail,
6985
+ branch bar, history) at once instead of re-growing it piece by
6986
+ piece; the refresh below re-proves every field right after. Only a
6987
+ cold mount seeds — a later effect run (tick / repo switch) must not
6988
+ roll live state back to a stale snapshot. */
6989
+ const cachedPage = readPaintPage(repoRoot)
6990
+ if (cachedPage !== undefined) {
6991
+ if (summary === null) setSummary(cachedPage.summary)
6992
+ if (branches === null) setBranches(cachedPage.branches)
6993
+ if (commits.length === 0) { setCommits(cachedPage.commits); setHasMore(cachedPage.hasMore) }
6994
+ }
6848
6995
  void refresh()
6849
6996
  return undefined
6997
+ // eslint-disable-next-line react-hooks/exhaustive-deps
6850
6998
  }, [repoRoot, refresh, tick])
6851
6999
 
6852
7000
  useEffect(() => {
@@ -7636,7 +7784,7 @@ window.__ModuleLoader__.load({
7636
7784
 
7637
7785
  const CSS = [
7638
7786
  '.dig-root{position:relative;display:flex;flex-direction:column;height:100%;min-height:0;background:transparent;color:var(--dsw-alias-label-primary);font-family:var(--dsw-font-family,system-ui,sans-serif);font-size:13px;line-height:1.5;font-weight:600;overflow:hidden}',
7639
- '.dig-topbar{display:flex;align-items:center;gap:6px;padding:4px 6px;flex:none;min-width:0;border-bottom:1px solid var(--dsw-alias-hairline,var(--dsw-alias-border-l1));overflow:hidden}',
7787
+ '.dig-topbar{display:flex;align-items:center;gap:6px;padding:4px 6px;flex:none;min-width:0;border-bottom:1px solid var(--dsw-alias-border-l2);overflow:hidden}',
7640
7788
  '.dig-topbar-spacer{flex:1;min-width:4px}',
7641
7789
  '.dig-busy{color:var(--dsw-alias-label-tertiary);flex:none;white-space:nowrap}',
7642
7790
  '.dig-track{color:var(--dsw-alias-label-secondary);flex:none;font-variant-numeric:tabular-nums;white-space:nowrap}',
@@ -7659,10 +7807,10 @@ window.__ModuleLoader__.load({
7659
7807
  '.dig-note{display:flex;align-items:center;gap:8px;padding:4px 8px;flex:none;color:var(--dsw-alias-label-secondary)}',
7660
7808
  '.dig-banner-text{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis}',
7661
7809
  '.dig-shell{flex:1;min-height:0;display:flex;overflow:hidden}',
7662
- '.dig-rail{width:32px;flex:none;display:flex;flex-direction:column;align-items:center;gap:2px;padding:4px 0;overflow:hidden;border-right:1px solid var(--dsw-alias-hairline,var(--dsw-alias-border-l1))}',
7810
+ '.dig-rail{width:32px;flex:none;display:flex;flex-direction:column;align-items:center;gap:2px;padding:4px 0;overflow:hidden;border-right:1px solid var(--dsw-alias-border-l2)}',
7663
7811
  '.dig-rail-gap{flex:1;min-height:2px}',
7664
7812
  '.dig-rail-gap-x{flex:1;min-width:2px}',
7665
- '.dig-rail-row{display:flex;align-items:center;gap:2px;padding:3px 6px;flex:none;overflow:hidden;border-bottom:1px solid var(--dsw-alias-hairline,var(--dsw-alias-border-l1))}',
7813
+ '.dig-rail-row{display:flex;align-items:center;gap:2px;padding:3px 6px;flex:none;overflow:hidden;border-bottom:1px solid var(--dsw-alias-border-l2)}',
7666
7814
  '.dig-rail-btn{display:inline-flex;align-items:center;justify-content:center;width:24px;height:24px;border:none;border-radius:var(--dsw-radius-sm,8px);background:transparent;color:var(--dsw-alias-label-secondary);cursor:pointer;padding:0;flex:none}',
7667
7815
  '.dig-rail-btn:hover:not(:disabled){background:var(--dsw-alias-interactive-bg-hover);color:var(--dsw-alias-label-primary)}',
7668
7816
  '.dig-rail-btn:disabled{opacity:.28;cursor:default}',
@@ -7745,13 +7893,13 @@ window.__ModuleLoader__.load({
7745
7893
  '.dig-body-stack{flex-direction:column}',
7746
7894
  '.dig-body-compact{flex-direction:column}',
7747
7895
  '.dig-pane{display:flex;flex-direction:column;min-height:0;min-width:0}',
7748
- '.dig-pane-tree{width:200px;flex:none;border-right:1px solid var(--dsw-alias-hairline,var(--dsw-alias-border-l1))}',
7749
- '.dig-pane-tree-stack{max-height:36%;flex:none;border-bottom:1px solid var(--dsw-alias-hairline,var(--dsw-alias-border-l1))}',
7896
+ '.dig-pane-tree{width:200px;flex:none;border-right:1px solid var(--dsw-alias-border-l2)}',
7897
+ '.dig-pane-tree-stack{max-height:36%;flex:none;border-bottom:1px solid var(--dsw-alias-border-l2)}',
7750
7898
  '.dig-pane-main{flex:1;min-width:0}',
7751
- '.dig-pane-changes{width:290px;flex:none;border-left:1px solid var(--dsw-alias-hairline,var(--dsw-alias-border-l1))}',
7752
- '.dig-pane-changes-stack{max-height:46%;flex:none;border-bottom:1px solid var(--dsw-alias-hairline,var(--dsw-alias-border-l1))}',
7899
+ '.dig-pane-changes{width:290px;flex:none;border-left:1px solid var(--dsw-alias-border-l2)}',
7900
+ '.dig-pane-changes-stack{max-height:46%;flex:none;border-bottom:1px solid var(--dsw-alias-border-l2)}',
7753
7901
  '.dig-compact-bar{display:flex;align-items:center;gap:6px;padding:4px 6px;flex:none}',
7754
- '.dig-compact-tree{max-height:42%;flex:none;border-bottom:1px solid var(--dsw-alias-hairline,var(--dsw-alias-border-l1))}',
7902
+ '.dig-compact-tree{max-height:42%;flex:none;border-bottom:1px solid var(--dsw-alias-border-l2)}',
7755
7903
  // A divider: an 8px hit area (a fingertip needs it) painted as a 1px hairline
7756
7904
  // that lights up on hover, focus or while dragging. touch-action:none is what
7757
7905
  // makes a touch drag resize the pane instead of scrolling the panel.
@@ -7835,7 +7983,7 @@ window.__ModuleLoader__.load({
7835
7983
  '.dig-ref-remote{color:#b083f0}',
7836
7984
  '.dig-ref-tag{color:var(--dsw-alias-state-warn-primary,var(--dsw-alias-brand-primary))}',
7837
7985
  '.dig-ref-more{color:var(--dsw-alias-label-tertiary)}',
7838
- '.dig-filters{display:flex;align-items:center;gap:4px;padding:4px 6px;flex:none;min-width:0;overflow-x:auto;overflow-y:hidden;border-bottom:1px solid var(--dsw-alias-hairline,var(--dsw-alias-border-l1))}',
7986
+ '.dig-filters{display:flex;align-items:center;gap:4px;padding:4px 6px;flex:none;min-width:0;overflow-x:auto;overflow-y:hidden;border-bottom:1px solid var(--dsw-alias-border-l2)}',
7839
7987
  '.dig-filter-text{flex:1 1 90px;min-width:80px;width:auto}',
7840
7988
  '.dig-filter-select{flex:none;max-width:118px;appearance:none;-webkit-appearance:none;background:transparent;border:1px solid var(--dsw-alias-border-l2);border-radius:var(--dsw-radius-sm,8px);color:var(--dsw-alias-label-secondary);font:inherit;font-weight:500;height:22px;padding:0 4px;cursor:pointer;text-overflow:ellipsis}',
7841
7989
  '.dig-filter-select:hover{background:var(--dsw-alias-interactive-bg-hover);color:var(--dsw-alias-label-primary)}',
@@ -7865,7 +8013,7 @@ window.__ModuleLoader__.load({
7865
8013
  '.dig-mini{display:inline-flex;align-items:center;justify-content:center;width:18px;height:18px;border:none;border-radius:var(--dsw-radius-xs,4px);background:transparent;color:var(--dsw-alias-label-secondary);cursor:pointer;opacity:0;flex:none;padding:0}',
7866
8014
  '.dig-row:hover .dig-mini{opacity:1}',
7867
8015
  '.dig-mini:hover{background:var(--dsw-alias-interactive-bg-hover);color:var(--dsw-alias-label-primary)}',
7868
- '.dig-commit-box{flex:none;padding:6px;display:flex;flex-direction:column;gap:6px;border-top:1px solid var(--dsw-alias-hairline,var(--dsw-alias-border-l1))}',
8016
+ '.dig-commit-box{flex:none;padding:6px;display:flex;flex-direction:column;gap:6px;border-top:1px solid var(--dsw-alias-border-l2)}',
7869
8017
  '.dig-commit-box-compact{flex-direction:row;align-items:center;gap:6px;padding:4px 6px;flex-wrap:wrap}',
7870
8018
  '.dig-textarea{width:100%;box-sizing:border-box;min-height:50px;resize:vertical;padding:5px 6px;border-radius:var(--dsw-radius-sm,8px);border:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-layer-1);color:var(--dsw-alias-label-primary);font:inherit;outline:none}',
7871
8019
  '.dig-textarea::placeholder{color:var(--dsw-alias-label-tertiary)}',
@@ -7887,7 +8035,7 @@ window.__ModuleLoader__.load({
7887
8035
  '.dig-detail-files-head{font-weight:600;margin-top:4px}',
7888
8036
  '.dig-detail-files{display:flex;flex-direction:column}',
7889
8037
  '.dig-mono{font-family:ui-monospace,SFMono-Regular,Menlo,monospace}',
7890
- '.dig-diff-pane{flex:none;max-height:55%;display:flex;flex-direction:column;min-height:0;border-top:1px solid var(--dsw-alias-hairline,var(--dsw-alias-border-l1))}',
8038
+ '.dig-diff-pane{flex:none;max-height:55%;display:flex;flex-direction:column;min-height:0;border-top:1px solid var(--dsw-alias-border-l2)}',
7891
8039
  '.dig-diff-head{display:flex;align-items:center;gap:8px;padding:3px 8px;color:var(--dsw-alias-label-tertiary);flex:none;min-width:0}',
7892
8040
  '.dig-diff-path{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;flex:1;min-width:0}',
7893
8041
  '.dig-diff{flex:1;overflow:auto;font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:12px;line-height:1.5;font-weight:500;min-height:0}',
@@ -7984,17 +8132,70 @@ window.__ModuleLoader__.load({
7984
8132
  return undefined
7985
8133
  }
7986
8134
 
7987
- function ConversationPanel(props) {
8135
+ /* The conversation shell re-renders on EVERY keystroke (the composer owns
8136
+ the draft state), and the view area re-renders with it. Without a bail-out
8137
+ at this boundary that cascade dragged the ENTIRE Git panel tree — branch
8138
+ rows, graph lanes, changes — through a synchronous render per keypress
8139
+ while the Git view was the active one (typing lag, reported against
8140
+ v0.13.0). The component consumes exactly three stable props (sessionId,
8141
+ t, ctx); everything else the slot passes (inspectCall, viewRequest, …)
8142
+ changes identity per shell render and is deliberately ignored, so the
8143
+ comparator is: same session, same bindings → skip the subtree. cwd moves
8144
+ through the sessions subscription inside, not through props. */
8145
+ const ConversationPanel = memo(function ConversationPanel(props) {
7988
8146
  const ctx = props.ctx
7989
8147
  const sessionId = typeof props.sessionId === 'string' ? props.sessionId : ''
7990
8148
  const [cwd, setCwd] = useState(() => sessionCwdOf(ctx, sessionId))
8149
+ /* The active conversation's view area is `flex: 1 0 auto; min-height:
8150
+ auto` — it GROWS with content and scrolls inside the shared
8151
+ conversation scrollport. An auto-height panel there measured
8152
+ content-tall (a 120-commit history, a long diff), so the width-vs-
8153
+ height chrome test flipped the wide seat into the stacked sidebar
8154
+ layout and everything shifted downward (v0.13.1 report). Pin the seat
8155
+ to the scrollport's VISIBLE height instead: the panel fills exactly
8156
+ the on-screen area and scrolls internally, the way the sidebar seats
8157
+ behave. */
8158
+ const seatRef = useRef(null)
8159
+ const [seatHeight, setSeatHeight] = useState(null)
8160
+ useEffect(() => {
8161
+ const el = seatRef.current
8162
+ if (el === null) return undefined
8163
+ const measure = () => {
8164
+ let scroll = null
8165
+ try { scroll = el.closest('[data-conversation-scroll]') } catch (error) { void error }
8166
+ if (scroll === null || scroll === undefined) return
8167
+ const offset = Math.max(0, el.getBoundingClientRect().top - scroll.getBoundingClientRect().top)
8168
+ const available = Math.round(scroll.clientHeight - offset - 8)
8169
+ if (available >= 240) setSeatHeight(available)
8170
+ }
8171
+ measure()
8172
+ const raf = requestAnimationFrame(() => { measure() })
8173
+ const late = setTimeout(() => { measure() }, 400)
8174
+ let observer
8175
+ try {
8176
+ const scroll = el.closest('[data-conversation-scroll]')
8177
+ if (scroll !== null && scroll !== undefined && typeof ResizeObserver === 'function') {
8178
+ observer = new ResizeObserver(() => { measure() })
8179
+ observer.observe(scroll)
8180
+ }
8181
+ } catch (error) { void error }
8182
+ window.addEventListener('resize', measure)
8183
+ return () => {
8184
+ cancelAnimationFrame(raf)
8185
+ clearTimeout(late)
8186
+ if (observer !== undefined) observer.disconnect()
8187
+ window.removeEventListener('resize', measure)
8188
+ }
8189
+ }, [])
7991
8190
  useEffect(() => {
7992
8191
  let alive = true
7993
8192
  const sync = () => { if (alive === true) setCwd(sessionCwdOf(ctx, sessionId)) }
7994
8193
  sync()
7995
8194
  /* The session list is a live store; without a subscription a later
7996
8195
  start (first message in a blank session) would leave the tab on the
7997
- no-repo chrome until the next remount. */
8196
+ no-repo chrome until the next remount. The notify walks are cheap:
8197
+ getSnapshot + a string compare, and setCwd with an identical string
8198
+ bails out without a render. */
7998
8199
  let unsubscribe
7999
8200
  try {
8000
8201
  const sessions = ctx.get('sessions')
@@ -8009,8 +8210,12 @@ window.__ModuleLoader__.load({
8009
8210
  }
8010
8211
  }, [ctx, sessionId])
8011
8212
  const scope = useMemo(() => ({ cwd: cwd, sessionId: sessionId }), [cwd, sessionId])
8012
- return E(LocaleLive, { ctx: ctx, scope: scope, t: props.t, visible: true })
8013
- }
8213
+ return E('div', {
8214
+ ref: seatRef,
8215
+ className: 'dig-conversation-seat',
8216
+ style: seatHeight === null ? undefined : { height: seatHeight + 'px' },
8217
+ }, E(LocaleLive, { ctx: ctx, scope: scope, t: props.t, visible: true }))
8218
+ }, (prev, next) => prev.sessionId === next.sessionId && prev.t === next.t && prev.ctx === next.ctx)
8014
8219
 
8015
8220
  function NativePanel(props) {
8016
8221
  const workspaces = typeof props.useWorkspaces === 'function'