Skip to content

Commit 730e7c4

Browse files
authored
Merge pull request #512 from miyaoka/fix/sidebar-task-relative-time
fix(sidebar): task の相対時刻を lastActivityAt 基準の adaptive setTimeout に置き換え
2 parents da088cd + 6044610 commit 730e7c4

14 files changed

Lines changed: 256 additions & 136 deletions

File tree

apps/renderer/eslint.config.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,9 @@ export default defineConfigWithVueTs(
3939
},
4040
],
4141

42-
// vue: reactive() を禁止。ref() のみ使用する(issue #501)
42+
// vue: reactive() / watchEffect() を禁止
43+
// - reactive: ref() のみ使用する(issue #501)
44+
// - watchEffect: 依存が暗黙的で誤発火 / 漏れの原因になるため watch() を使う
4345
"no-restricted-imports": [
4446
"error",
4547
{
@@ -49,6 +51,11 @@ export default defineConfigWithVueTs(
4951
importNames: ["reactive"],
5052
message: "Use ref() instead. See issue #501.",
5153
},
54+
{
55+
name: "vue",
56+
importNames: ["watchEffect"],
57+
message: "Use watch() with explicit sources instead.",
58+
},
5259
],
5360
},
5461
],

apps/renderer/src/features/changes/ChangesPane.vue

Lines changed: 14 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@ Changed files tree. Shows HEAD vs working directory by default, or a selected co
3636
<script setup lang="ts">
3737
import type { GitCommit, GitFileChange } from "@gozd/proto";
3838
import { tryCatch } from "@gozd/shared";
39-
import { computed, ref, watch, watchEffect } from "vue";
39+
import { computed, ref, watch } from "vue";
4040
import { useNotificationStore } from "../../shared/notification";
4141
import { rpcGitCommitFiles, useGitGraphStore } from "../git-graph";
4242
import {
@@ -152,15 +152,19 @@ const fileCount = computed(() => fileChanges.value.length);
152152
*/
153153
const tree = ref<ChangesTreeNode[]>([]);
154154
155-
watchEffect(() => {
156-
const result = tryCatch(() => buildChangesTree(fileChanges.value));
157-
if (result.ok) {
158-
tree.value = result.value;
159-
return;
160-
}
161-
tree.value = [];
162-
notify.error("Failed to build changes tree", result.error);
163-
});
155+
watch(
156+
fileChanges,
157+
(changes) => {
158+
const result = tryCatch(() => buildChangesTree(changes));
159+
if (result.ok) {
160+
tree.value = result.value;
161+
return;
162+
}
163+
tree.value = [];
164+
notify.error("Failed to build changes tree", result.error);
165+
},
166+
{ immediate: true },
167+
);
164168
165169
/** 折りたたみ中フォルダの fullPath 集合(デフォルトは全展開) */
166170
const collapsedFolders = ref<Set<string>>(new Set());

apps/renderer/src/features/git-graph/GitGraphPane.vue

Lines changed: 18 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -16,16 +16,7 @@ import type { GitCommit, GitPullRequest } from "@gozd/proto";
1616
import { tryCatch } from "@gozd/shared";
1717
import { useElementSize, useIntervalFn } from "@vueuse/core";
1818
import { storeToRefs } from "pinia";
19-
import {
20-
computed,
21-
nextTick,
22-
onMounted,
23-
onUnmounted,
24-
ref,
25-
useTemplateRef,
26-
watch,
27-
watchEffect,
28-
} from "vue";
19+
import { computed, nextTick, onMounted, onUnmounted, ref, useTemplateRef, watch } from "vue";
2920
import { useNotificationStore } from "../../shared/notification";
3021
import { useRepoStore } from "../../shared/repo";
3122
import { onMessage } from "../../shared/rpc";
@@ -538,18 +529,23 @@ const detailWidth = ref(320);
538529
const detailOpen = ref(true);
539530
540531
// コンテナ幅縮小時に detailWidth をクランプし、収まらなければ自動で閉じる
541-
// rootWidth が 0(マウント前)のときはスキップ
542-
watchEffect(() => {
543-
if (!detailOpen.value || rootWidth.value === 0) return;
544-
const available = rootWidth.value - GRAPH_LIST_MIN_WIDTH - DETAIL_HANDLE_WIDTH;
545-
if (available < DETAIL_MIN_WIDTH) {
546-
detailOpen.value = false;
547-
return;
548-
}
549-
if (detailWidth.value > available) {
550-
detailWidth.value = Math.max(DETAIL_MIN_WIDTH, available);
551-
}
552-
});
532+
// rootWidth が 0(マウント前)のときはスキップ。
533+
// 書き換え対象の detailOpen / detailWidth は source に含めない
534+
watch(
535+
rootWidth,
536+
(width) => {
537+
if (!detailOpen.value || width === 0) return;
538+
const available = width - GRAPH_LIST_MIN_WIDTH - DETAIL_HANDLE_WIDTH;
539+
if (available < DETAIL_MIN_WIDTH) {
540+
detailOpen.value = false;
541+
return;
542+
}
543+
if (detailWidth.value > available) {
544+
detailWidth.value = Math.max(DETAIL_MIN_WIDTH, available);
545+
}
546+
},
547+
{ immediate: true },
548+
);
553549
554550
const graphListRef = ref<HTMLElement | null>(null);
555551
const scrollContainer = ref<HTMLElement | null>(null);

apps/renderer/src/features/layout/MainLayout.vue

Lines changed: 30 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@
1616

1717
<script setup lang="ts">
1818
import { useWindowSize } from "@vueuse/core";
19-
import { computed, onUnmounted, ref, useTemplateRef, watch, watchEffect } from "vue";
19+
import { computed, onUnmounted, ref, useTemplateRef, watch } from "vue";
2020
import { useCommandRegistry, useContextKeys } from "../../shared/command";
2121
import { useRepoStore } from "../../shared/repo";
2222
import { GitGraphPane } from "../git-graph";
@@ -124,12 +124,16 @@ const maxPreviewWidth = computed(() => {
124124
);
125125
});
126126
127-
// ウィンドウ縮小時に Preview 幅をクランプ
128-
watchEffect(() => {
129-
if (previewWidth.value > maxPreviewWidth.value) {
130-
previewWidth.value = Math.max(PREVIEW_MIN_WIDTH, maxPreviewWidth.value);
131-
}
132-
});
127+
// ウィンドウ縮小時に Preview 幅をクランプ。書き換え対象 previewWidth は source に含めない
128+
watch(
129+
maxPreviewWidth,
130+
(maxW) => {
131+
if (previewWidth.value > maxW) {
132+
previewWidth.value = Math.max(PREVIEW_MIN_WIDTH, maxW);
133+
}
134+
},
135+
{ immediate: true },
136+
);
133137
134138
/** ドラッグ開始時に popover 左側の空きスペースを返す(Navigator + 開閉ボタン分を除く) */
135139
const getPreviewBeforeSize = () =>
@@ -140,9 +144,13 @@ const getPreviewAfterSize = () =>
140144
previewPopoverRef.value?.getBoundingClientRect().width ?? previewWidth.value;
141145
142146
// previewVisible context key を実際の表示状態と同期
143-
watchEffect(() => {
144-
contextKeys.set("previewVisible", previewOpen.value);
145-
});
147+
watch(
148+
previewOpen,
149+
(open) => {
150+
contextKeys.set("previewVisible", open);
151+
},
152+
{ immediate: true },
153+
);
146154
147155
/** :popover-open でガードして二重呼び出し例外を防止 */
148156
function openPreview() {
@@ -185,13 +193,18 @@ function getCenterTerminalHeight(): number {
185193
return centerTerminalRef.value?.offsetHeight ?? TERMINAL_MIN_HEIGHT;
186194
}
187195
188-
// ウィンドウ縦縮小時に gitGraphHeight をクランプ(Terminal が潰れるのを防ぐ)
189-
watchEffect(() => {
190-
const maxGitGraph = windowHeight.value - TERMINAL_MIN_HEIGHT - HANDLE_WIDTH;
191-
if (gitGraphHeight.value > maxGitGraph) {
192-
gitGraphHeight.value = Math.max(GIT_GRAPH_MIN_HEIGHT, maxGitGraph);
193-
}
194-
});
196+
// ウィンドウ縦縮小時に gitGraphHeight をクランプ(Terminal が潰れるのを防ぐ)。
197+
// 書き換え対象 gitGraphHeight は source に含めない
198+
watch(
199+
windowHeight,
200+
(h) => {
201+
const maxGitGraph = h - TERMINAL_MIN_HEIGHT - HANDLE_WIDTH;
202+
if (gitGraphHeight.value > maxGitGraph) {
203+
gitGraphHeight.value = Math.max(GIT_GRAPH_MIN_HEIGHT, maxGitGraph);
204+
}
205+
},
206+
{ immediate: true },
207+
);
195208
</script>
196209

197210
<template>

apps/renderer/src/features/navigator/NavigatorPane.vue

Lines changed: 15 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ Filer(上)と Changes(下)を垂直分割で表示するコンテナ。
1212

1313
<script setup lang="ts">
1414
import { useElementSize } from "@vueuse/core";
15-
import { ref, useTemplateRef, watchEffect } from "vue";
15+
import { ref, useTemplateRef, watch } from "vue";
1616
import { useRepoStore } from "../../shared/repo";
1717
import { ChangesPane } from "../changes";
1818
import { FilerPane } from "../filer";
@@ -32,14 +32,20 @@ const { height: containerHeight } = useElementSize(containerRef);
3232
const changesHeight = ref(360);
3333
3434
// コンテナ縮小時に changesHeight をクランプ(Filer が潰れるのを防ぐ)
35-
// useElementSize は mount 直後 0 を返すため、計測前は clamp をスキップする
36-
watchEffect(() => {
37-
if (containerHeight.value <= 0) return;
38-
const maxChanges = containerHeight.value - FILER_MIN_HEIGHT - HANDLE_HEIGHT;
39-
if (changesHeight.value > maxChanges) {
40-
changesHeight.value = Math.max(CHANGES_MIN_HEIGHT, maxChanges);
41-
}
42-
});
35+
// useElementSize は mount 直後 0 を返すため、計測前は clamp をスキップする。
36+
// watch source は外因(containerHeight)だけにする。changesHeight は書き換え対象なので
37+
// source に含めると再帰発火経路が混入する(user resize は別ロジックでクランプ済み)。
38+
watch(
39+
containerHeight,
40+
(h) => {
41+
if (h <= 0) return;
42+
const maxChanges = h - FILER_MIN_HEIGHT - HANDLE_HEIGHT;
43+
if (changesHeight.value > maxChanges) {
44+
changesHeight.value = Math.max(CHANGES_MIN_HEIGHT, maxChanges);
45+
}
46+
},
47+
{ immediate: true },
48+
);
4349
4450
/** Filer ペインの DOM 実測高さ(flex-1 のため v-model 不可) */
4551
function getFilerHeight(): number {

apps/renderer/src/features/sidebar/SidebarPane.vue

Lines changed: 0 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,6 @@ import { move } from "@dnd-kit/helpers";
3030
import { DragDropProvider } from "@dnd-kit/vue";
3131
import type { Task, WorktreeEntry } from "@gozd/proto";
3232
import { tryCatch } from "@gozd/shared";
33-
import { useIntervalFn } from "@vueuse/core";
3433
import { computed, ref } from "vue";
3534
import { useNotificationStore } from "../../shared/notification";
3635
import { useRepoStore } from "../../shared/repo";
@@ -61,13 +60,6 @@ const { isCreatingFor, handleWorktreeSelect, addWorktree, handleWorktreeRemove }
6160
showConfirm,
6261
});
6362
64-
// --- 経過時間表示用の現在時刻 ---
65-
66-
const now = ref(Date.now());
67-
useIntervalFn(() => {
68-
now.value = Date.now();
69-
}, 1000);
70-
7163
// --- メニュー ---
7264
7365
const sidebarMenuRef = ref<InstanceType<typeof SidebarMenu>>();
@@ -234,7 +226,6 @@ const activeRootWorktree = computed(() => {
234226
:edit-mode="editMode"
235227
:active-dir="worktreeStore.dir"
236228
:is-creating="isCreatingFor(rootDir)"
237-
:now="now"
238229
:get-resumeable-session-count="terminalStore.getResumeableSessionCount"
239230
:get-terminal-count="getTerminalCount"
240231
:get-focused-pty-id="getFocusedPtyId"

apps/renderer/src/features/sidebar/features/repo/RepoSection.vue

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,6 @@ const props = defineProps<{
3333
editMode: boolean;
3434
activeDir: string | undefined;
3535
isCreating: boolean;
36-
now: number;
3736
getResumeableSessionCount: (dir: string) => number;
3837
getTerminalCount: (dir: string) => number;
3938
getFocusedPtyId: (dir: string) => number | undefined;
@@ -145,7 +144,6 @@ function onHeaderClick() {
145144
:focused-pty-id="getFocusedPtyId(wt.path)"
146145
:terminal-count="getTerminalCount(wt.path)"
147146
:resumeable-session-count="getResumeableSessionCount(wt.path)"
148-
:now="now"
149147
@select-wt="emit('selectWt', $event)"
150148
@select-task="(w, t) => emit('selectTask', w, t)"
151149
@open-menu="(anchorEl, wt2) => emit('openWorktreeMenu', anchorEl, wt2, rootDir)"

apps/renderer/src/features/sidebar/features/worktree/TaskRow.vue

Lines changed: 9 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -8,17 +8,20 @@ WCAG 1.4.1 準拠で色 + 形 + aria-label の 3 軸で状態を表現する。
88

99
## 相対時刻の起点
1010

11-
- working → lastActivityAt
12-
- それ以外 → enteredAt
13-
- live PTY 無し(resumable) → undefined(時刻表示なし)
11+
`status.lastActivityAt`(Claude が最後に動いた時刻)を全 state で使う。state 遷移時刻ではなく
12+
活動時刻を基準にすることで、working → idle / asking などで "now" にリセットされない。
13+
status 不在(resumable)時は `task.createdAt` にフォールバックする。算出ロジックは
14+
`taskBaseTime.resolveTaskBaseTime` を SSOT として使う。
1415
</doc>
1516

1617
<script setup lang="ts">
1718
import type { Task } from "@gozd/proto";
1819
import { computed } from "vue";
1920
import type { ClaudeStatus } from "../../../terminal";
2021
import { extractAskingText, extractFirstSentence } from "../../../voicevox";
21-
import { formatRelativeTime, taskDisplayTitle } from "../../utils";
22+
import { resolveTaskBaseTime } from "../../taskBaseTime";
23+
import { useRelativeTime } from "../../useRelativeTime";
24+
import { taskDisplayTitle } from "../../utils";
2225
2326
type StateKind = "asking" | "working" | "done" | "idle" | "resumable";
2427
@@ -59,7 +62,6 @@ const props = defineProps<{
5962
task: Task;
6063
status: ClaudeStatus | undefined;
6164
active: boolean;
62-
now: number;
6365
}>();
6466
6567
const emit = defineEmits<{
@@ -70,17 +72,9 @@ const stateKind = computed<StateKind>(() => props.status?.state ?? "resumable");
7072
const visual = computed(() => STATE_VISUAL[stateKind.value]);
7173
const title = computed(() => taskDisplayTitle(props.task.body));
7274
73-
/** 相対時刻の基準時刻。resumable / status 不在のときは時刻表示なし */
74-
const baseTime = computed<number | undefined>(() => {
75-
const status = props.status;
76-
if (status === undefined) return undefined;
77-
if (status.state === "working") return status.lastActivityAt;
78-
return status.enteredAt;
79-
});
75+
const baseTime = computed<number | undefined>(() => resolveTaskBaseTime(props.status, props.task));
8076
81-
const relativeTime = computed(() =>
82-
baseTime.value === undefined ? "" : formatRelativeTime(baseTime.value, props.now),
83-
);
77+
const relativeTime = useRelativeTime(baseTime);
8478
8579
const bubbleText = computed<string | undefined>(() => {
8680
const status = props.status;

apps/renderer/src/features/sidebar/features/worktree/WtCard.vue

Lines changed: 3 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import { computed } from "vue";
2222
import type { ClaudeStatus } from "../../../terminal";
2323
import { useTerminalStore } from "../../../terminal";
2424
import { computeStatusIcons, StatusIcons } from "../../../worktree";
25+
import { resolveTaskBaseTime } from "../../taskBaseTime";
2526
import { branchLabel as resolveBranchLabel, hasChanges } from "../../utils";
2627
import TaskRow from "./TaskRow.vue";
2728
@@ -41,7 +42,6 @@ const props = defineProps<{
4142
focusedPtyId: number | undefined;
4243
terminalCount: number;
4344
resumeableSessionCount: number;
44-
now: number;
4545
}>();
4646
4747
const emit = defineEmits<{
@@ -83,23 +83,17 @@ function resolveStateKey(status: ClaudeStatus | undefined, ptyId: number | undef
8383
return "idle";
8484
}
8585
86-
function resolveBaseTime(status: ClaudeStatus | undefined, fallback: number): number {
87-
if (status === undefined) return fallback;
88-
if (status.state === "working") return status.lastActivityAt;
89-
return status.enteredAt;
90-
}
91-
9286
const tasksWithStatus = computed<TaskWithStatus[]>(() => {
9387
const list = props.wt.tasks.map<TaskWithStatus>((task) => {
9488
const status = terminalStore.getClaudeStatusBySessionId(task.id);
9589
const ptyId = terminalStore.getPtyIdBySessionId(task.id);
96-
const fallback = Date.parse(task.createdAt);
9790
return {
9891
task,
9992
status,
10093
ptyId,
10194
stateKey: resolveStateKey(status, ptyId),
102-
baseTime: resolveBaseTime(status, Number.isNaN(fallback) ? 0 : fallback),
95+
// sort 用なので createdAt パース失敗時 (proto 契約違反) は 0 に倒す
96+
baseTime: resolveTaskBaseTime(status, task) ?? 0,
10397
};
10498
});
10599
return list.sort((a, b) => {
@@ -188,7 +182,6 @@ function onHeaderClick() {
188182
:task="entry.task"
189183
:status="entry.status"
190184
:active="focusedTaskId === entry.task.id"
191-
:now="now"
192185
@select="(t) => emit('selectTask', wt, t)"
193186
/>
194187
</div>

0 commit comments

Comments
 (0)