claude-code-kanban 3.2.2 → 3.2.3

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/lib/parsers.js CHANGED
@@ -1,6 +1,7 @@
1
1
  const fs = require('fs');
2
2
  const { readFileSync, existsSync, readdirSync, statSync } = fs;
3
3
  const path = require('path');
4
+ const { StringDecoder } = require('string_decoder');
4
5
 
5
6
  function parseTask(raw) {
6
7
  const task = typeof raw === 'string' ? JSON.parse(raw) : raw;
@@ -171,52 +172,97 @@ function readCustomTitle(jsonlPath, existingStat) {
171
172
  }
172
173
  }
173
174
 
175
+ const SCRAPE_CWD_RE = /"cwd"\s*:\s*"((?:[^"\\]|\\.)*)"/;
176
+ const SCRAPE_SLUG_RE = /"slug"\s*:\s*"((?:[^"\\]|\\.)*)"/;
177
+ const SCRAPE_GITBRANCH_RE = /"gitBranch"\s*:\s*"((?:[^"\\]|\\.)*)"/;
178
+
179
+ function scrapeScalarFromBlob(blob, re) {
180
+ const m = blob.match(re);
181
+ if (!m) return null;
182
+ try { return JSON.parse(`"${m[1]}"`); } catch (e) { return null; }
183
+ }
184
+
174
185
  function readSessionInfoFromJsonl(jsonlPath) {
175
186
  const result = { slug: null, projectPath: null, cwd: null, gitBranch: null, customTitle: null };
176
187
  let stat;
188
+ let fd;
189
+ // State shared across head-chunk parse, leftover flush, and tail parse.
190
+ let lastCwdFromHead = null;
191
+ const applyLine = (line) => {
192
+ try {
193
+ const data = JSON.parse(line);
194
+ if (data.slug) result.slug = data.slug;
195
+ if (data.cwd) {
196
+ if (!result.projectPath) result.projectPath = data.cwd;
197
+ lastCwdFromHead = data.cwd;
198
+ }
199
+ if (data.gitBranch) result.gitBranch = data.gitBranch;
200
+ } catch (e) {}
201
+ };
177
202
  try {
178
203
  stat = statSync(jsonlPath);
179
- const fd = fs.openSync(jsonlPath, 'r');
180
- const HEAD_SIZE = 16384;
204
+ fd = fs.openSync(jsonlPath, 'r');
205
+ const CHUNK_SIZE = 16384;
181
206
  const TAIL_SIZE = 16384;
182
-
183
- const headBuf = Buffer.alloc(Math.min(HEAD_SIZE, stat.size));
184
- const hn = fs.readSync(fd, headBuf, 0, headBuf.length, 0);
185
- let lastCwdFromHead = null;
186
- for (const line of headBuf.toString('utf8', 0, hn).split('\n')) {
187
- try {
188
- const data = JSON.parse(line);
189
- if (data.slug) result.slug = data.slug;
190
- if (data.cwd) {
191
- if (!result.projectPath) result.projectPath = data.cwd;
192
- lastCwdFromHead = data.cwd;
193
- }
194
- if (data.gitBranch) result.gitBranch = data.gitBranch;
195
- if (result.slug && result.projectPath && result.gitBranch) break;
196
- } catch (e) {}
207
+ // Stream head in chunks with StringDecoder to preserve multi-byte UTF-8
208
+ // codepoints across chunk boundaries. Scan up to HEAD_MAX so the last cwd
209
+ // in the window wins sessions that change cwd mid-run must surface it.
210
+ const HEAD_MAX = 1048576;
211
+ const decoder = new StringDecoder('utf8');
212
+ const buf = Buffer.alloc(CHUNK_SIZE);
213
+ let leftover = '';
214
+ let offset = 0;
215
+ while (offset < stat.size && offset < HEAD_MAX) {
216
+ const len = Math.min(CHUNK_SIZE, stat.size - offset);
217
+ const n = fs.readSync(fd, buf, 0, len, offset);
218
+ if (n === 0) break;
219
+ offset += n;
220
+ const text = leftover + decoder.write(n === buf.length ? buf : buf.slice(0, n));
221
+ const lines = text.split('\n');
222
+ leftover = lines.pop();
223
+ for (const line of lines) applyLine(line);
224
+ }
225
+ leftover += decoder.end();
226
+ if (leftover) applyLine(leftover);
227
+ // Oversized first line (e.g. multi-MB inline image) left leftover unparsed
228
+ // above; scrape scalars out of it so we don't fall through to the tail and
229
+ // pick a mid-session cwd as projectPath.
230
+ if (!result.projectPath && leftover && leftover.length > CHUNK_SIZE) {
231
+ const scrapedCwd = scrapeScalarFromBlob(leftover, SCRAPE_CWD_RE);
232
+ if (scrapedCwd) { result.projectPath = scrapedCwd; lastCwdFromHead = scrapedCwd; }
233
+ if (!result.slug) result.slug = scrapeScalarFromBlob(leftover, SCRAPE_SLUG_RE);
234
+ if (!result.gitBranch) result.gitBranch = scrapeScalarFromBlob(leftover, SCRAPE_GITBRANCH_RE);
197
235
  }
198
236
 
199
237
  result.cwd = lastCwdFromHead;
200
238
 
201
- if ((!result.slug || !result.projectPath || !result.gitBranch) && stat.size > HEAD_SIZE) {
202
- const tailStart = stat.size - TAIL_SIZE;
239
+ // Tail scan: always runs when the file extends past the head window, so
240
+ // `cwd` reflects the latest value even in long sessions where a late cwd
241
+ // switch sits past HEAD_MAX. Also backfills projectPath/slug/gitBranch
242
+ // when the head couldn't find them. projectPath is NOT overwritten — it
243
+ // stays anchored to the earliest cwd seen.
244
+ if (stat.size > offset) {
245
+ const tailStart = Math.max(offset, stat.size - TAIL_SIZE);
203
246
  const tailBuf = Buffer.alloc(TAIL_SIZE);
204
247
  const tn = fs.readSync(fd, tailBuf, 0, TAIL_SIZE, tailStart);
205
248
  const lines = tailBuf.toString('utf8', 0, tn).split('\n');
249
+ let latestTailCwd = null;
206
250
  for (let i = lines.length - 1; i >= 0; i--) {
207
251
  try {
208
252
  const data = JSON.parse(lines[i]);
209
253
  if (!result.slug && data.slug) result.slug = data.slug;
210
254
  if (!result.projectPath && data.cwd) result.projectPath = data.cwd;
211
255
  if (!result.gitBranch && data.gitBranch) result.gitBranch = data.gitBranch;
212
- if (!result.cwd && data.cwd) result.cwd = data.cwd;
213
- if (result.slug && result.projectPath && result.gitBranch) break;
256
+ if (!latestTailCwd && data.cwd) latestTailCwd = data.cwd;
257
+ if (latestTailCwd && result.slug && result.projectPath && result.gitBranch) break;
214
258
  } catch (e) {}
215
259
  }
260
+ if (latestTailCwd) result.cwd = latestTailCwd;
216
261
  }
217
-
218
- fs.closeSync(fd);
219
- } catch (e) {}
262
+ } catch (e) {
263
+ } finally {
264
+ if (fd !== undefined) { try { fs.closeSync(fd); } catch (e) {} }
265
+ }
220
266
 
221
267
  result.customTitle = readCustomTitle(jsonlPath, stat);
222
268
  return result;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-code-kanban",
3
- "version": "3.2.2",
3
+ "version": "3.2.3",
4
4
  "description": "A web-based Kanban board for viewing Claude Code tasks with agent teams support",
5
5
  "main": "server.js",
6
6
  "bin": {
package/public/app.js CHANGED
@@ -4810,44 +4810,40 @@ async function showSessionInfoModal(sessionId) {
4810
4810
  const session = sessions.find((s) => s.id === sessionId);
4811
4811
  if (!session) return;
4812
4812
 
4813
- const promises = [];
4814
-
4815
- // Fetch team config
4816
- let teamConfig = null;
4817
- if (session.isTeam) {
4818
- const teamId = session.teamName || sessionId;
4819
- promises.push(
4820
- fetch(`/api/teams/${teamId}`)
4813
+ // Open modal immediately with session metadata (cwd / path / branch are
4814
+ // already in-memory). Plan / team / tasks are fetched in the background
4815
+ // and re-rendered when they arrive, so the modal doesn't block on network.
4816
+ _planSessionId = sessionId;
4817
+ const cachedTasks = currentSessionId === sessionId ? currentTasks : [];
4818
+ showInfoModal(session, null, cachedTasks, null);
4819
+
4820
+ const rerender = (teamConfig, tasks, planContent) => {
4821
+ if (_planSessionId !== sessionId) return; // user opened a different modal
4822
+ const modal = document.getElementById('team-modal');
4823
+ if (!modal?.classList.contains('visible')) return; // user closed modal — don't reopen
4824
+ showInfoModal(session, teamConfig, tasks, planContent);
4825
+ };
4826
+
4827
+ const teamPromise = session.isTeam
4828
+ ? fetch(`/api/teams/${session.teamName || sessionId}`)
4821
4829
  .then((r) => (r.ok ? r.json() : null))
4822
4830
  .catch(() => null)
4823
- .then((data) => {
4824
- teamConfig = data;
4825
- }),
4826
- );
4827
- }
4831
+ : Promise.resolve(null);
4828
4832
 
4829
- // Fetch plan
4830
- let planContent = null;
4831
- promises.push(
4832
- fetch(`/api/sessions/${sessionId}/plan`)
4833
- .then((r) => (r.ok ? r.json() : null))
4834
- .catch(() => null)
4835
- .then((data) => {
4836
- planContent = data?.content || null;
4837
- }),
4838
- );
4833
+ const planPromise = fetch(`/api/sessions/${sessionId}/plan`)
4834
+ .then((r) => (r.ok ? r.json() : null))
4835
+ .catch(() => null)
4836
+ .then((data) => data?.content || null);
4839
4837
 
4840
- await Promise.all(promises);
4838
+ const tasksPromise =
4839
+ cachedTasks.length > 0
4840
+ ? Promise.resolve(cachedTasks)
4841
+ : fetch(`/api/sessions/${sessionId}`)
4842
+ .then((r) => (r.ok ? r.json() : []))
4843
+ .catch(() => []);
4841
4844
 
4842
- let tasks = currentSessionId === sessionId ? currentTasks : [];
4843
- if (tasks.length === 0) {
4844
- try {
4845
- const r = await fetch(`/api/sessions/${sessionId}`);
4846
- if (r.ok) tasks = await r.json();
4847
- } catch {}
4848
- }
4849
- _planSessionId = sessionId;
4850
- showInfoModal(session, teamConfig, tasks, planContent);
4845
+ const [teamConfig, planContent, tasks] = await Promise.all([teamPromise, planPromise, tasksPromise]);
4846
+ rerender(teamConfig, tasks, planContent);
4851
4847
  }
4852
4848
 
4853
4849
  let _infoModalSessionId = null;
@@ -4891,7 +4887,7 @@ function showInfoModal(session, teamConfig, tasks, planContent) {
4891
4887
  const projectName = session.project.split(/[/\\]/).pop();
4892
4888
  infoRows.push(['Project', projectName, { openPath: session.projectDir }]);
4893
4889
  infoRows.push(['Path', session.project, { openPath: session.project }]);
4894
- if (session.cwd && session.cwd !== session.project) {
4890
+ if (session.cwd) {
4895
4891
  infoRows.push(['CWD', session.cwd, { openPath: session.cwd }]);
4896
4892
  }
4897
4893
  if (session.gitBranch) {
@@ -5004,6 +5000,7 @@ function showInfoModal(session, teamConfig, tasks, planContent) {
5004
5000
  }
5005
5001
 
5006
5002
  bodyEl.innerHTML = html;
5003
+ const alreadyVisible = modal.classList.contains('visible');
5007
5004
  _infoModalSessionId = session.id;
5008
5005
  updateStickyBtnState();
5009
5006
  updateDismissBtnState();
@@ -5011,6 +5008,8 @@ function showInfoModal(session, teamConfig, tasks, planContent) {
5011
5008
  if (costBtn) costBtn.style.display = window.__HUB__?.enabled || appConfig.costUrl ? '' : 'none';
5012
5009
  modal.classList.add('visible');
5013
5010
 
5011
+ if (alreadyVisible) return; // re-render during deferred hydration — key handler already attached
5012
+
5014
5013
  const keyHandler = (e) => {
5015
5014
  if (e.key === 'Escape') {
5016
5015
  if (document.getElementById('plan-modal').classList.contains('visible')) return;
@@ -5024,6 +5023,7 @@ function showInfoModal(session, teamConfig, tasks, planContent) {
5024
5023
 
5025
5024
  function closeTeamModal() {
5026
5025
  document.getElementById('team-modal').classList.remove('visible');
5026
+ _planSessionId = null;
5027
5027
  }
5028
5028
 
5029
5029
  // biome-ignore lint/correctness/noUnusedVariables: used in HTML