fix: ui improvements for pr panel, git operations, and task sidebar (#359)
This commit is contained in:
@@ -13,9 +13,10 @@ import (
|
||||
)
|
||||
|
||||
// registerE2EResetRoutes registers the E2E data-reset endpoint.
|
||||
// The endpoint is only available when KANDEV_MOCK_AGENT=true (i.e., during E2E tests).
|
||||
// The endpoint is available when KANDEV_MOCK_AGENT is "true" or "only" (dev/E2E modes).
|
||||
func registerE2EResetRoutes(router *gin.Engine, repo *sqliterepo.Repository, log *logger.Logger) {
|
||||
if os.Getenv("KANDEV_MOCK_AGENT") != "true" {
|
||||
mockMode := os.Getenv("KANDEV_MOCK_AGENT")
|
||||
if mockMode != "true" && mockMode != "only" {
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -10,15 +10,25 @@ import (
|
||||
)
|
||||
|
||||
// Provide creates and loads the agent registry.
|
||||
//
|
||||
// KANDEV_MOCK_AGENT controls mock-agent availability:
|
||||
// - "only" → E2E mode: only register mock agent, skip all others
|
||||
// - "true" → Dev mode: load all agents AND enable mock agent
|
||||
// - unset → Production: load all agents, mock agent disabled
|
||||
func Provide(log *logger.Logger) (*Registry, func() error, error) {
|
||||
reg := NewRegistry(log)
|
||||
|
||||
if os.Getenv("KANDEV_MOCK_AGENT") == "true" {
|
||||
// Only register mock agent — skip slow agent discovery for all others
|
||||
mockMode := os.Getenv("KANDEV_MOCK_AGENT")
|
||||
if mockMode == "only" {
|
||||
// E2E mode: only register mock agent — skip agent discovery for all others
|
||||
_ = reg.Register(agents.NewMockAgent())
|
||||
configureMockAgent(reg, log)
|
||||
} else {
|
||||
reg.LoadDefaults()
|
||||
if mockMode == "true" {
|
||||
// Dev mode: enable mock agent alongside all other agents
|
||||
configureMockAgent(reg, log)
|
||||
}
|
||||
}
|
||||
|
||||
return reg, func() error { return nil }, nil
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
package registry
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestProvide_MockAgentModes(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
envValue string
|
||||
wantMockEnabled bool
|
||||
wantOnlyMock bool // only mock-agent registered (no other defaults)
|
||||
wantDefaultsCount int // minimum expected agent count (0 = don't check)
|
||||
}{
|
||||
{
|
||||
name: "unset: all defaults loaded, mock disabled",
|
||||
envValue: "",
|
||||
wantMockEnabled: false,
|
||||
wantOnlyMock: false,
|
||||
wantDefaultsCount: 2, // at least auggie + mock-agent
|
||||
},
|
||||
{
|
||||
name: "true: all defaults loaded, mock enabled",
|
||||
envValue: "true",
|
||||
wantMockEnabled: true,
|
||||
wantOnlyMock: false,
|
||||
},
|
||||
{
|
||||
name: "only: only mock-agent registered and enabled",
|
||||
envValue: "only",
|
||||
wantMockEnabled: true,
|
||||
wantOnlyMock: true,
|
||||
},
|
||||
{
|
||||
name: "arbitrary value: treated as unset",
|
||||
envValue: "false",
|
||||
wantMockEnabled: false,
|
||||
wantOnlyMock: false,
|
||||
wantDefaultsCount: 2,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Setenv("KANDEV_MOCK_AGENT", tt.envValue)
|
||||
|
||||
log := newTestLogger()
|
||||
reg, cleanup, err := Provide(log)
|
||||
if err != nil {
|
||||
t.Fatalf("Provide() error: %v", err)
|
||||
}
|
||||
defer cleanup() //nolint:errcheck
|
||||
|
||||
// Check mock-agent presence and enabled state
|
||||
mock, hasMock := reg.Get("mock-agent")
|
||||
if !hasMock {
|
||||
t.Fatal("mock-agent should always be registered")
|
||||
}
|
||||
if mock.Enabled() != tt.wantMockEnabled {
|
||||
t.Errorf("mock-agent Enabled() = %v, want %v", mock.Enabled(), tt.wantMockEnabled)
|
||||
}
|
||||
|
||||
// Check agent count
|
||||
all := reg.List()
|
||||
if tt.wantOnlyMock {
|
||||
if len(all) != 1 {
|
||||
t.Errorf("only mode: expected 1 agent, got %d", len(all))
|
||||
}
|
||||
}
|
||||
if tt.wantDefaultsCount > 0 && len(all) < tt.wantDefaultsCount {
|
||||
t.Errorf("expected at least %d agents, got %d", tt.wantDefaultsCount, len(all))
|
||||
}
|
||||
|
||||
// In non-only mode, verify other default agents exist
|
||||
if !tt.wantOnlyMock {
|
||||
if !reg.Exists("auggie") {
|
||||
t.Error("expected default agent 'auggie' to be loaded")
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -240,7 +240,7 @@ type ghReview struct {
|
||||
} `json:"user"`
|
||||
State string `json:"state"`
|
||||
Body string `json:"body"`
|
||||
SubmittedAt time.Time `json:"submittedAt"`
|
||||
SubmittedAt time.Time `json:"submitted_at"`
|
||||
}
|
||||
|
||||
func (c *GHClient) ListPRReviews(ctx context.Context, owner, repo string, number int) ([]PRReview, error) {
|
||||
|
||||
+4
-4
@@ -23,10 +23,10 @@ export async function runDev({ repoRoot, backendPort, webPort }: DevOptions): Pr
|
||||
const ports = await pickPorts(backendPort, webPort);
|
||||
const dbPath = path.join(repoRoot, "apps", "backend", "kandev.db");
|
||||
|
||||
const extra: Record<string, string> = { KANDEV_DATABASE_PATH: dbPath };
|
||||
if (process.env.KANDEV_MOCK_AGENT) {
|
||||
extra.KANDEV_MOCK_AGENT = process.env.KANDEV_MOCK_AGENT;
|
||||
}
|
||||
const extra: Record<string, string> = {
|
||||
KANDEV_DATABASE_PATH: dbPath,
|
||||
KANDEV_MOCK_AGENT: process.env.KANDEV_MOCK_AGENT || "true",
|
||||
};
|
||||
const backendEnv = buildBackendEnv({ ports, extra });
|
||||
const webEnv = buildWebEnv({ ports, includeMcp: true, debug: true });
|
||||
const logLevel =
|
||||
|
||||
@@ -8,6 +8,20 @@ import { Button } from "./button";
|
||||
import { IconX } from "@tabler/icons-react";
|
||||
|
||||
function Dialog({ ...props }: React.ComponentProps<typeof DialogPrimitive.Root>) {
|
||||
const openRef = React.useRef(props.open);
|
||||
openRef.current = props.open;
|
||||
|
||||
React.useEffect(() => {
|
||||
return () => {
|
||||
// Safety cleanup: Radix Dialog sets pointer-events: none on body when
|
||||
// modal. If the dialog unmounts while open (e.g. page navigation),
|
||||
// Radix never cleans up, leaving the page unclickable.
|
||||
if (openRef.current) {
|
||||
document.body.style.removeProperty("pointer-events");
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
return <DialogPrimitive.Root data-slot="dialog" {...props} />;
|
||||
}
|
||||
|
||||
|
||||
Generated
+137
-13
@@ -394,7 +394,7 @@ importers:
|
||||
version: 0.55.1
|
||||
next:
|
||||
specifier: 16.1.5
|
||||
version: 16.1.5(@playwright/test@1.58.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
|
||||
version: 16.1.5(@babel/core@7.28.6)(@playwright/test@1.58.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
|
||||
next-themes:
|
||||
specifier: ^0.4.6
|
||||
version: 0.4.6(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
|
||||
@@ -422,6 +422,12 @@ importers:
|
||||
recharts:
|
||||
specifier: 2.15.4
|
||||
version: 2.15.4(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
|
||||
rehype-raw:
|
||||
specifier: ^7.0.0
|
||||
version: 7.0.0
|
||||
rehype-sanitize:
|
||||
specifier: ^6.0.0
|
||||
version: 6.0.0
|
||||
remark-breaks:
|
||||
specifier: ^4.0.0
|
||||
version: 4.0.0
|
||||
@@ -442,7 +448,7 @@ importers:
|
||||
version: 2.0.7(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
|
||||
styled-jsx:
|
||||
specifier: 5.1.6
|
||||
version: 5.1.6(react@19.2.3)
|
||||
version: 5.1.6(@babel/core@7.28.6)(react@19.2.3)
|
||||
tailwind-merge:
|
||||
specifier: ^3.4.0
|
||||
version: 3.4.0
|
||||
@@ -4110,6 +4116,10 @@ packages:
|
||||
resolution: {integrity: sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==}
|
||||
engines: {node: '>=0.12'}
|
||||
|
||||
entities@6.0.1:
|
||||
resolution: {integrity: sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==}
|
||||
engines: {node: '>=0.12'}
|
||||
|
||||
env-paths@2.2.1:
|
||||
resolution: {integrity: sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==}
|
||||
engines: {node: '>=6'}
|
||||
@@ -4602,15 +4612,33 @@ packages:
|
||||
resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
hast-util-from-parse5@8.0.3:
|
||||
resolution: {integrity: sha512-3kxEVkEKt0zvcZ3hCRYI8rqrgwtlIOFMWkbclACvjlDw8Li9S2hk/d51OI0nr/gIpdMHNepwgOKqZ/sy0Clpyg==}
|
||||
|
||||
hast-util-parse-selector@4.0.0:
|
||||
resolution: {integrity: sha512-wkQCkSYoOGCRKERFWcxMVMOcYE2K1AaNLU8DXS9arxnLOUEWbOXKXiJUNzEpqZ3JOKpnha3jkFrumEjVliDe7A==}
|
||||
|
||||
hast-util-raw@9.1.0:
|
||||
resolution: {integrity: sha512-Y8/SBAHkZGoNkpzqqfCldijcuUKh7/su31kEBp67cFY09Wy0mTRgtsLYsiIxMJxlu0f6AA5SUTbDR8K0rxnbUw==}
|
||||
|
||||
hast-util-sanitize@5.0.2:
|
||||
resolution: {integrity: sha512-3yTWghByc50aGS7JlGhk61SPenfE/p1oaFeNwkOOyrscaOkMGrcW9+Cy/QAIOBpZxP1yqDIzFMR0+Np0i0+usg==}
|
||||
|
||||
hast-util-to-html@9.0.5:
|
||||
resolution: {integrity: sha512-OguPdidb+fbHQSU4Q4ZiLKnzWo8Wwsf5bZfbvu7//a9oTYoqD/fWpe96NuHkoS9h0ccGOTe0C4NGXdtS0iObOw==}
|
||||
|
||||
hast-util-to-jsx-runtime@2.3.6:
|
||||
resolution: {integrity: sha512-zl6s8LwNyo1P9uw+XJGvZtdFF1GdAkOg8ujOw+4Pyb76874fLps4ueHXDhXWdk6YHQ6OgUtinliG7RsYvCbbBg==}
|
||||
|
||||
hast-util-to-parse5@8.0.1:
|
||||
resolution: {integrity: sha512-MlWT6Pjt4CG9lFCjiz4BH7l9wmrMkfkJYCxFwKQic8+RTZgWPuWxwAfjJElsXkex7DJjfSJsQIt931ilUgmwdA==}
|
||||
|
||||
hast-util-whitespace@3.0.0:
|
||||
resolution: {integrity: sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==}
|
||||
|
||||
hastscript@9.0.1:
|
||||
resolution: {integrity: sha512-g7df9rMFX/SPi34tyGCyUBREQoKkapwdY/T04Qn9TDWfHhAYt4/I0gMVirzK5wEzeUqIjEB+LXC/ypb7Aqno5w==}
|
||||
|
||||
headers-polyfill@4.0.3:
|
||||
resolution: {integrity: sha512-IScLbePpkvO846sIwOtOTDjutRMWdXdJmXdMvk6gCBHxFO8d+QKOQedyZSxFTTFYRSmlgSTDtXqqq4pcenBXLQ==}
|
||||
|
||||
@@ -5608,6 +5636,9 @@ packages:
|
||||
resolution: {integrity: sha512-TXfryirbmq34y8QBwgqCVLi+8oA3oWx2eAnSn62ITyEhEYaWRlVZ2DvMM9eZbMs/RfxPu/PK/aBLyGj4IrqMHw==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
parse5@7.3.0:
|
||||
resolution: {integrity: sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==}
|
||||
|
||||
parseurl@1.3.3:
|
||||
resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==}
|
||||
engines: {node: '>= 0.8'}
|
||||
@@ -5947,6 +5978,12 @@ packages:
|
||||
resolution: {integrity: sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
rehype-raw@7.0.0:
|
||||
resolution: {integrity: sha512-/aE8hCfKlQeA8LmyeyQvQF3eBiLRGNlfBJEvWH7ivp9sBqs7TNqBL5X3v157rM4IFETqDnIOO+z5M/biZbo9Ww==}
|
||||
|
||||
rehype-sanitize@6.0.0:
|
||||
resolution: {integrity: sha512-CsnhKNsyI8Tub6L4sm5ZFsme4puGfc6pYylvXo1AeqaGbjOYyzNv3qZPwvs0oMJ39eryyeOdmxwUIo94IpEhqg==}
|
||||
|
||||
remark-breaks@4.0.0:
|
||||
resolution: {integrity: sha512-IjEjJOkH4FuJvHZVIW0QCDWxcG96kCq7An/KVH2NfJe6rKZU2AsHeB3OEjPNRxi4QC34Xdx7I2KGYn6IpT7gxQ==}
|
||||
|
||||
@@ -6546,6 +6583,9 @@ packages:
|
||||
react: ^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc
|
||||
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc
|
||||
|
||||
vfile-location@5.0.3:
|
||||
resolution: {integrity: sha512-5yXvWDEgqeiYiBe1lbxYF7UMAIm/IcopxMHrMQDq3nvKcjPKIhZklUKL+AE7J7uApI4kwe2snsK+eI6UTj9EHg==}
|
||||
|
||||
vfile-message@4.0.3:
|
||||
resolution: {integrity: sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw==}
|
||||
|
||||
@@ -6639,6 +6679,9 @@ packages:
|
||||
w3c-keyname@2.2.8:
|
||||
resolution: {integrity: sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==}
|
||||
|
||||
web-namespaces@2.0.1:
|
||||
resolution: {integrity: sha512-bKr1DkiNa2krS7qxNtdrtHAmzuYGFQLiQ13TsorsdT6ULTkPLKuu5+GsFpDlg6JFjUTwX2DyhMPG2be8uPrqsQ==}
|
||||
|
||||
web-streams-polyfill@3.3.3:
|
||||
resolution: {integrity: sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==}
|
||||
engines: {node: '>= 8'}
|
||||
@@ -10435,6 +10478,8 @@ snapshots:
|
||||
|
||||
entities@4.5.0: {}
|
||||
|
||||
entities@6.0.1: {}
|
||||
|
||||
env-paths@2.2.1: {}
|
||||
|
||||
error-ex@1.3.4:
|
||||
@@ -10610,8 +10655,8 @@ snapshots:
|
||||
'@next/eslint-plugin-next': 16.1.1
|
||||
eslint: 9.39.2(jiti@2.6.1)
|
||||
eslint-import-resolver-node: 0.3.9
|
||||
eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.53.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1)))(eslint@9.39.2(jiti@2.6.1))
|
||||
eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.53.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.53.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1)))(eslint@9.39.2(jiti@2.6.1)))(eslint@9.39.2(jiti@2.6.1))
|
||||
eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.2(jiti@2.6.1))
|
||||
eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.53.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.2(jiti@2.6.1))
|
||||
eslint-plugin-jsx-a11y: 6.10.2(eslint@9.39.2(jiti@2.6.1))
|
||||
eslint-plugin-react: 7.37.5(eslint@9.39.2(jiti@2.6.1))
|
||||
eslint-plugin-react-hooks: 7.0.1(eslint@9.39.2(jiti@2.6.1))
|
||||
@@ -10633,7 +10678,7 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.53.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1)))(eslint@9.39.2(jiti@2.6.1)):
|
||||
eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.2(jiti@2.6.1)):
|
||||
dependencies:
|
||||
'@nolyfill/is-core-module': 1.0.39
|
||||
debug: 4.4.3
|
||||
@@ -10644,22 +10689,22 @@ snapshots:
|
||||
tinyglobby: 0.2.15
|
||||
unrs-resolver: 1.11.1
|
||||
optionalDependencies:
|
||||
eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.53.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.53.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1)))(eslint@9.39.2(jiti@2.6.1)))(eslint@9.39.2(jiti@2.6.1))
|
||||
eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.53.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.2(jiti@2.6.1))
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
eslint-module-utils@2.12.1(@typescript-eslint/parser@8.53.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.53.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1)))(eslint@9.39.2(jiti@2.6.1)))(eslint@9.39.2(jiti@2.6.1)):
|
||||
eslint-module-utils@2.12.1(@typescript-eslint/parser@8.53.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.2(jiti@2.6.1)):
|
||||
dependencies:
|
||||
debug: 3.2.7
|
||||
optionalDependencies:
|
||||
'@typescript-eslint/parser': 8.53.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)
|
||||
eslint: 9.39.2(jiti@2.6.1)
|
||||
eslint-import-resolver-node: 0.3.9
|
||||
eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.53.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1)))(eslint@9.39.2(jiti@2.6.1))
|
||||
eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.2(jiti@2.6.1))
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.53.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.53.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1)))(eslint@9.39.2(jiti@2.6.1)))(eslint@9.39.2(jiti@2.6.1)):
|
||||
eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.53.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.2(jiti@2.6.1)):
|
||||
dependencies:
|
||||
'@rtsao/scc': 1.1.0
|
||||
array-includes: 3.1.9
|
||||
@@ -10670,7 +10715,7 @@ snapshots:
|
||||
doctrine: 2.1.0
|
||||
eslint: 9.39.2(jiti@2.6.1)
|
||||
eslint-import-resolver-node: 0.3.9
|
||||
eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.53.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.53.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1)))(eslint@9.39.2(jiti@2.6.1)))(eslint@9.39.2(jiti@2.6.1))
|
||||
eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.53.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.2(jiti@2.6.1))
|
||||
hasown: 2.0.2
|
||||
is-core-module: 2.16.1
|
||||
is-glob: 4.0.3
|
||||
@@ -11162,6 +11207,43 @@ snapshots:
|
||||
dependencies:
|
||||
function-bind: 1.1.2
|
||||
|
||||
hast-util-from-parse5@8.0.3:
|
||||
dependencies:
|
||||
'@types/hast': 3.0.4
|
||||
'@types/unist': 3.0.3
|
||||
devlop: 1.1.0
|
||||
hastscript: 9.0.1
|
||||
property-information: 7.1.0
|
||||
vfile: 6.0.3
|
||||
vfile-location: 5.0.3
|
||||
web-namespaces: 2.0.1
|
||||
|
||||
hast-util-parse-selector@4.0.0:
|
||||
dependencies:
|
||||
'@types/hast': 3.0.4
|
||||
|
||||
hast-util-raw@9.1.0:
|
||||
dependencies:
|
||||
'@types/hast': 3.0.4
|
||||
'@types/unist': 3.0.3
|
||||
'@ungap/structured-clone': 1.3.0
|
||||
hast-util-from-parse5: 8.0.3
|
||||
hast-util-to-parse5: 8.0.1
|
||||
html-void-elements: 3.0.0
|
||||
mdast-util-to-hast: 13.2.1
|
||||
parse5: 7.3.0
|
||||
unist-util-position: 5.0.0
|
||||
unist-util-visit: 5.0.0
|
||||
vfile: 6.0.3
|
||||
web-namespaces: 2.0.1
|
||||
zwitch: 2.0.4
|
||||
|
||||
hast-util-sanitize@5.0.2:
|
||||
dependencies:
|
||||
'@types/hast': 3.0.4
|
||||
'@ungap/structured-clone': 1.3.0
|
||||
unist-util-position: 5.0.0
|
||||
|
||||
hast-util-to-html@9.0.5:
|
||||
dependencies:
|
||||
'@types/hast': 3.0.4
|
||||
@@ -11196,10 +11278,28 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
hast-util-to-parse5@8.0.1:
|
||||
dependencies:
|
||||
'@types/hast': 3.0.4
|
||||
comma-separated-tokens: 2.0.3
|
||||
devlop: 1.1.0
|
||||
property-information: 7.1.0
|
||||
space-separated-tokens: 2.0.2
|
||||
web-namespaces: 2.0.1
|
||||
zwitch: 2.0.4
|
||||
|
||||
hast-util-whitespace@3.0.0:
|
||||
dependencies:
|
||||
'@types/hast': 3.0.4
|
||||
|
||||
hastscript@9.0.1:
|
||||
dependencies:
|
||||
'@types/hast': 3.0.4
|
||||
comma-separated-tokens: 2.0.3
|
||||
hast-util-parse-selector: 4.0.0
|
||||
property-information: 7.1.0
|
||||
space-separated-tokens: 2.0.2
|
||||
|
||||
headers-polyfill@4.0.3: {}
|
||||
|
||||
hermes-estree@0.25.1: {}
|
||||
@@ -12168,7 +12268,7 @@ snapshots:
|
||||
react: 19.2.3
|
||||
react-dom: 19.2.3(react@19.2.3)
|
||||
|
||||
next@16.1.5(@playwright/test@1.58.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3):
|
||||
next@16.1.5(@babel/core@7.28.6)(@playwright/test@1.58.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3):
|
||||
dependencies:
|
||||
'@next/env': 16.1.5
|
||||
'@swc/helpers': 0.5.15
|
||||
@@ -12177,7 +12277,7 @@ snapshots:
|
||||
postcss: 8.4.31
|
||||
react: 19.2.3
|
||||
react-dom: 19.2.3(react@19.2.3)
|
||||
styled-jsx: 5.1.6(react@19.2.3)
|
||||
styled-jsx: 5.1.6(@babel/core@7.28.6)(react@19.2.3)
|
||||
optionalDependencies:
|
||||
'@next/swc-darwin-arm64': 16.1.5
|
||||
'@next/swc-darwin-x64': 16.1.5
|
||||
@@ -12365,6 +12465,10 @@ snapshots:
|
||||
|
||||
parse-ms@4.0.0: {}
|
||||
|
||||
parse5@7.3.0:
|
||||
dependencies:
|
||||
entities: 6.0.1
|
||||
|
||||
parseurl@1.3.3: {}
|
||||
|
||||
path-browserify@1.0.1: {}
|
||||
@@ -12810,6 +12914,17 @@ snapshots:
|
||||
gopd: 1.2.0
|
||||
set-function-name: 2.0.2
|
||||
|
||||
rehype-raw@7.0.0:
|
||||
dependencies:
|
||||
'@types/hast': 3.0.4
|
||||
hast-util-raw: 9.1.0
|
||||
vfile: 6.0.3
|
||||
|
||||
rehype-sanitize@6.0.0:
|
||||
dependencies:
|
||||
'@types/hast': 3.0.4
|
||||
hast-util-sanitize: 5.0.2
|
||||
|
||||
remark-breaks@4.0.0:
|
||||
dependencies:
|
||||
'@types/mdast': 4.0.4
|
||||
@@ -13297,10 +13412,12 @@ snapshots:
|
||||
dependencies:
|
||||
inline-style-parser: 0.2.7
|
||||
|
||||
styled-jsx@5.1.6(react@19.2.3):
|
||||
styled-jsx@5.1.6(@babel/core@7.28.6)(react@19.2.3):
|
||||
dependencies:
|
||||
client-only: 0.0.1
|
||||
react: 19.2.3
|
||||
optionalDependencies:
|
||||
'@babel/core': 7.28.6
|
||||
|
||||
stylis@4.3.6: {}
|
||||
|
||||
@@ -13596,6 +13713,11 @@ snapshots:
|
||||
- '@types/react'
|
||||
- '@types/react-dom'
|
||||
|
||||
vfile-location@5.0.3:
|
||||
dependencies:
|
||||
'@types/unist': 3.0.3
|
||||
vfile: 6.0.3
|
||||
|
||||
vfile-message@4.0.3:
|
||||
dependencies:
|
||||
'@types/unist': 3.0.3
|
||||
@@ -13705,6 +13827,8 @@ snapshots:
|
||||
|
||||
w3c-keyname@2.2.8: {}
|
||||
|
||||
web-namespaces@2.0.1: {}
|
||||
|
||||
web-streams-polyfill@3.3.3: {}
|
||||
|
||||
whatwg-mimetype@3.0.0: {}
|
||||
|
||||
@@ -9,13 +9,13 @@ import {
|
||||
IconAlertTriangle,
|
||||
IconGitMerge,
|
||||
IconCheck,
|
||||
IconLoader2,
|
||||
} from "@tabler/icons-react";
|
||||
import { Badge } from "@kandev/ui/badge";
|
||||
import { Button } from "@kandev/ui/button";
|
||||
import { Separator } from "@kandev/ui/separator";
|
||||
import { ScrollArea } from "@kandev/ui/scroll-area";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip";
|
||||
import { Spinner } from "@kandev/ui/spinner";
|
||||
import { useAppStore } from "@/components/state-provider";
|
||||
import { useActiveTaskPR } from "@/hooks/domains/github/use-task-pr";
|
||||
import { prPanelLabel } from "@/components/github/pr-utils";
|
||||
@@ -165,7 +165,8 @@ function ApproveButton({
|
||||
const { toast } = useToast();
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
if (taskPR.state !== "open") return null;
|
||||
const liveState = feedback?.pr.state ?? taskPR.state;
|
||||
if (liveState !== "open") return null;
|
||||
|
||||
const alreadyApproved = feedback?.reviews.some(
|
||||
(r) => r.state === "APPROVED" && r.author === feedback.pr.author_login,
|
||||
@@ -206,6 +207,38 @@ function ApproveButton({
|
||||
function PRDetailContent({ taskPR, sessionId }: { taskPR: TaskPR; sessionId: string }) {
|
||||
const { feedback, loading, refresh } = usePRFeedback(taskPR.owner, taskPR.repo, taskPR.pr_number);
|
||||
const { addAsContext } = useAddPRFeedbackAsContext(sessionId, taskPR.pr_number);
|
||||
const setTaskPR = useAppStore((s) => s.setTaskPR);
|
||||
|
||||
// Sync live feedback data back to the store so topbar/other consumers stay up to date.
|
||||
// Use primitive deps to avoid re-render loops from object reference changes.
|
||||
const prState = taskPR.state;
|
||||
const prMergedAt = taskPR.merged_at ?? null;
|
||||
const prClosedAt = taskPR.closed_at ?? null;
|
||||
const prAdditions = taskPR.additions;
|
||||
const prDeletions = taskPR.deletions;
|
||||
const prTaskId = taskPR.task_id;
|
||||
useEffect(() => {
|
||||
if (!feedback) return;
|
||||
const livePR = feedback.pr;
|
||||
if (
|
||||
livePR.state !== prState ||
|
||||
(livePR.merged_at ?? null) !== prMergedAt ||
|
||||
(livePR.closed_at ?? null) !== prClosedAt ||
|
||||
livePR.additions !== prAdditions ||
|
||||
livePR.deletions !== prDeletions
|
||||
) {
|
||||
setTaskPR(prTaskId, {
|
||||
...taskPR,
|
||||
state: livePR.state,
|
||||
additions: livePR.additions,
|
||||
deletions: livePR.deletions,
|
||||
merged_at: livePR.merged_at,
|
||||
closed_at: livePR.closed_at,
|
||||
});
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [feedback, prState, prMergedAt, prClosedAt, prAdditions, prDeletions, prTaskId, setTaskPR]);
|
||||
|
||||
const metrics = derivePanelMetrics(taskPR, feedback);
|
||||
|
||||
return (
|
||||
@@ -222,7 +255,7 @@ function PRDetailContent({ taskPR, sessionId }: { taskPR: TaskPR; sessionId: str
|
||||
<div className="p-3 space-y-1">
|
||||
{loading && !feedback && (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<Spinner className="h-6 w-6" />
|
||||
<IconLoader2 className="h-6 w-6 text-blue-500 animate-spin" />
|
||||
</div>
|
||||
)}
|
||||
{feedback && (
|
||||
@@ -385,9 +418,10 @@ function PRHeader({
|
||||
loading: boolean;
|
||||
onRefresh: () => void;
|
||||
}) {
|
||||
const liveState = feedback?.pr.state ?? taskPR.state;
|
||||
const isDraft = feedback?.pr.draft ?? false;
|
||||
const isMergeable = feedback?.pr.mergeable ?? true;
|
||||
const showWarnings = isDraft || (!isMergeable && taskPR.state === "open");
|
||||
const showWarnings = isDraft || (!isMergeable && liveState === "open");
|
||||
|
||||
return (
|
||||
<div className="p-3 space-y-2">
|
||||
@@ -398,7 +432,7 @@ function PRHeader({
|
||||
<ApproveButton taskPR={taskPR} feedback={feedback} onRefresh={onRefresh} />
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 flex-wrap">
|
||||
<StateBadge state={taskPR.state} />
|
||||
<StateBadge state={liveState} />
|
||||
<span className="text-xs text-muted-foreground">#{taskPR.pr_number}</span>
|
||||
<code className="text-[10px] px-1 py-0.5 bg-muted rounded font-mono">
|
||||
{taskPR.head_branch}
|
||||
@@ -418,7 +452,7 @@ function PRHeader({
|
||||
Draft
|
||||
</Badge>
|
||||
)}
|
||||
{!isMergeable && taskPR.state === "open" && (
|
||||
{!isMergeable && liveState === "open" && (
|
||||
<span className="flex items-center gap-1 text-[10px] text-yellow-600 dark:text-yellow-400">
|
||||
<IconAlertTriangle className="h-3 w-3" />
|
||||
Not mergeable
|
||||
|
||||
@@ -1,19 +1,27 @@
|
||||
import { useState } from "react";
|
||||
import ReactMarkdown from "react-markdown";
|
||||
import rehypeRaw from "rehype-raw";
|
||||
import rehypeSanitize, { defaultSchema } from "rehype-sanitize";
|
||||
import { IconMessagePlus, IconChevronDown, IconChevronRight } from "@tabler/icons-react";
|
||||
import { Button } from "@kandev/ui/button";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip";
|
||||
import { markdownComponents, remarkPlugins } from "@/components/shared/markdown-components";
|
||||
|
||||
export function formatTimeAgo(dateStr: string): string {
|
||||
const diff = Date.now() - new Date(dateStr).getTime();
|
||||
const date = new Date(dateStr);
|
||||
if (isNaN(date.getTime())) return "";
|
||||
const diff = Date.now() - date.getTime();
|
||||
if (diff < 0) return "just now";
|
||||
const minutes = Math.floor(diff / 60000);
|
||||
if (minutes < 1) return "just now";
|
||||
if (minutes < 60) return `${minutes}m ago`;
|
||||
const hours = Math.floor(minutes / 60);
|
||||
if (hours < 24) return `${hours}h ago`;
|
||||
const days = Math.floor(hours / 24);
|
||||
return `${days}d ago`;
|
||||
if (days < 7) return `${days}d ago`;
|
||||
if (days < 30) return `${Math.floor(days / 7)}w ago`;
|
||||
if (days < 365) return `${Math.floor(days / 30)}mo ago`;
|
||||
return `${Math.floor(days / 365)}y ago`;
|
||||
}
|
||||
|
||||
function formatMs(ms: number): string {
|
||||
@@ -153,10 +161,20 @@ export function AddToContextButton({
|
||||
);
|
||||
}
|
||||
|
||||
/** Sanitization schema: default safe tags + details/summary for collapsible sections. */
|
||||
const sanitizeSchema = {
|
||||
...defaultSchema,
|
||||
tagNames: [...(defaultSchema.tagNames ?? []), "details", "summary"],
|
||||
};
|
||||
|
||||
export function PRMarkdownBody({ body }: { body: string }) {
|
||||
return (
|
||||
<div className="markdown-body max-w-none text-sm">
|
||||
<ReactMarkdown remarkPlugins={remarkPlugins} components={markdownComponents}>
|
||||
<ReactMarkdown
|
||||
remarkPlugins={remarkPlugins}
|
||||
rehypePlugins={[rehypeRaw, [rehypeSanitize, sanitizeSchema]]}
|
||||
components={markdownComponents}
|
||||
>
|
||||
{body}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
|
||||
@@ -16,6 +16,14 @@ function prIconColor(state: string): string {
|
||||
}
|
||||
|
||||
function PRStatusIcon({ pr }: { pr: TaskPR }) {
|
||||
// Terminal states take priority
|
||||
if (pr.state === "merged") {
|
||||
return <IconCheck className="h-3 w-3 text-purple-500" />;
|
||||
}
|
||||
if (pr.state === "closed") {
|
||||
return <IconX className="h-3 w-3 text-muted-foreground" />;
|
||||
}
|
||||
// Review/check states only matter for open PRs
|
||||
if (pr.checks_state === "failure" || pr.review_state === "changes_requested") {
|
||||
return <IconX className="h-3 w-3 text-red-500" />;
|
||||
}
|
||||
@@ -25,12 +33,6 @@ function PRStatusIcon({ pr }: { pr: TaskPR }) {
|
||||
if (pr.checks_state === "pending" || pr.review_state === "pending") {
|
||||
return <IconClock className="h-3 w-3 text-yellow-500" />;
|
||||
}
|
||||
if (pr.state === "merged") {
|
||||
return <IconCheck className="h-3 w-3 text-purple-500" />;
|
||||
}
|
||||
if (pr.state === "closed") {
|
||||
return <IconX className="h-3 w-3 text-muted-foreground" />;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
@@ -300,8 +300,8 @@ export const TaskFormInputs = memo(function TaskFormInputs({
|
||||
rows={2}
|
||||
className={
|
||||
isSessionMode
|
||||
? "min-h-[120px] max-h-[240px] resize-none overflow-auto"
|
||||
: "min-h-[96px] max-h-[240px] resize-y overflow-auto"
|
||||
? "min-h-[120px] max-h-[240px] resize-none overflow-auto text-[13px]"
|
||||
: "min-h-[96px] max-h-[240px] resize-y overflow-auto text-[13px]"
|
||||
}
|
||||
required={isSessionMode}
|
||||
disabled={disabled}
|
||||
|
||||
@@ -167,10 +167,9 @@ export function useTaskSubmitHandlers({
|
||||
const response = await launchSession(request);
|
||||
|
||||
const newSessionId = response?.session_id;
|
||||
onOpenChange(false);
|
||||
if (newSessionId) {
|
||||
router.push(linkToSession(newSessionId));
|
||||
} else {
|
||||
onOpenChange(false);
|
||||
}
|
||||
} catch (error) {
|
||||
toast({
|
||||
@@ -299,8 +298,8 @@ export function useTaskSubmitHandlers({
|
||||
);
|
||||
const newSessionId = taskResponse.session_id ?? taskResponse.primary_session_id ?? null;
|
||||
onSuccess?.(taskResponse, "create", { taskSessionId: newSessionId });
|
||||
onOpenChange(false);
|
||||
if (planMode && newSessionId) {
|
||||
onOpenChange(false);
|
||||
activatePlanMode({
|
||||
sessionId: newSessionId,
|
||||
taskId: taskResponse.id,
|
||||
@@ -310,8 +309,6 @@ export function useTaskSubmitHandlers({
|
||||
});
|
||||
} else if (isPassthroughProfile && newSessionId) {
|
||||
router.push(linkToSession(newSessionId));
|
||||
} else {
|
||||
onOpenChange(false);
|
||||
}
|
||||
},
|
||||
[
|
||||
@@ -348,6 +345,7 @@ export function useTaskSubmitHandlers({
|
||||
);
|
||||
const newSessionId = taskResponse.session_id ?? taskResponse.primary_session_id ?? null;
|
||||
onSuccess?.(taskResponse, "create", { taskSessionId: newSessionId });
|
||||
onOpenChange(false);
|
||||
if (newSessionId) {
|
||||
activatePlanMode({
|
||||
sessionId: newSessionId,
|
||||
@@ -356,8 +354,6 @@ export function useTaskSubmitHandlers({
|
||||
setPlanMode,
|
||||
router,
|
||||
});
|
||||
} else {
|
||||
onOpenChange(false);
|
||||
}
|
||||
},
|
||||
[
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
IconGitCherryPick,
|
||||
IconGitMerge,
|
||||
IconArrowRight,
|
||||
IconLoader2,
|
||||
} from "@tabler/icons-react";
|
||||
import { Button } from "@kandev/ui/button";
|
||||
import { HoverCard, HoverCardContent, HoverCardTrigger } from "@kandev/ui/hover-card";
|
||||
@@ -60,14 +61,19 @@ function BranchHoverCard({
|
||||
function PullDropdown({
|
||||
behindCount,
|
||||
isLoading,
|
||||
loadingOperation,
|
||||
onPull,
|
||||
onRebase,
|
||||
}: {
|
||||
behindCount: number;
|
||||
isLoading: boolean;
|
||||
loadingOperation: string | null;
|
||||
onPull: () => void;
|
||||
onRebase: () => void;
|
||||
}) {
|
||||
const isPulling = loadingOperation === "pull";
|
||||
const isRebasing = loadingOperation === "rebase";
|
||||
const isPullRelated = isPulling || isRebasing;
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
@@ -77,10 +83,18 @@ function PullDropdown({
|
||||
className="h-5 text-[11px] px-1.5 gap-1 cursor-pointer"
|
||||
disabled={isLoading}
|
||||
>
|
||||
<IconCloudDownload className="h-3 w-3" />
|
||||
Pull
|
||||
{behindCount > 0 && <span className="text-yellow-500 text-[10px]">{behindCount}</span>}
|
||||
<IconChevronDown className="h-2.5 w-2.5 text-muted-foreground" />
|
||||
{isPullRelated ? (
|
||||
<IconLoader2 className="h-3 w-3 animate-spin" />
|
||||
) : (
|
||||
<IconCloudDownload className="h-3 w-3" />
|
||||
)}
|
||||
{isPulling && "Pulling…"}
|
||||
{!isPulling && isRebasing && "Rebasing…"}
|
||||
{!isPulling && !isRebasing && "Pull"}
|
||||
{behindCount > 0 && !isPullRelated && (
|
||||
<span className="text-yellow-500 text-[10px]">{behindCount}</span>
|
||||
)}
|
||||
{!isPullRelated && <IconChevronDown className="h-2.5 w-2.5 text-muted-foreground" />}
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-44">
|
||||
@@ -111,6 +125,7 @@ export function ChangesPanelHeader({
|
||||
baseBranchDisplay,
|
||||
behindCount,
|
||||
isLoading,
|
||||
loadingOperation,
|
||||
onOpenDiffAll,
|
||||
onOpenReview,
|
||||
onPull,
|
||||
@@ -123,6 +138,7 @@ export function ChangesPanelHeader({
|
||||
baseBranchDisplay: string;
|
||||
behindCount: number;
|
||||
isLoading: boolean;
|
||||
loadingOperation: string | null;
|
||||
onOpenDiffAll?: () => void;
|
||||
onOpenReview?: () => void;
|
||||
onPull: () => void;
|
||||
@@ -163,6 +179,7 @@ export function ChangesPanelHeader({
|
||||
<PullDropdown
|
||||
behindCount={behindCount}
|
||||
isLoading={isLoading}
|
||||
loadingOperation={loadingOperation}
|
||||
onPull={onPull}
|
||||
onRebase={onRebase}
|
||||
/>
|
||||
|
||||
@@ -216,6 +216,7 @@ function useChangesPRHandlers(
|
||||
toast: Toast,
|
||||
taskTitle: string | undefined,
|
||||
baseBranch: string | undefined,
|
||||
firstCommitMessage: string | undefined,
|
||||
) {
|
||||
const [prDialogOpen, setPrDialogOpen] = useState(false);
|
||||
const [prTitle, setPrTitle] = useState("");
|
||||
@@ -223,10 +224,10 @@ function useChangesPRHandlers(
|
||||
const [prDraft, setPrDraft] = useState(true);
|
||||
|
||||
const handleOpenPRDialog = useCallback(() => {
|
||||
setPrTitle(taskTitle || "");
|
||||
setPrTitle(firstCommitMessage || taskTitle || "");
|
||||
setPrBody("");
|
||||
setPrDialogOpen(true);
|
||||
}, [taskTitle]);
|
||||
}, [firstCommitMessage, taskTitle]);
|
||||
const handleCreatePR = useCallback(async () => {
|
||||
if (!prTitle.trim()) return;
|
||||
setPrDialogOpen(false);
|
||||
@@ -275,11 +276,11 @@ export function useChangesDialogHandlers(
|
||||
gitOps: GitOps,
|
||||
toast: Toast,
|
||||
handleGitOperation: GitOperationFn,
|
||||
taskTitle: string | undefined,
|
||||
baseBranch: string | undefined,
|
||||
prDefaults: { taskTitle?: string; baseBranch?: string; firstCommitMessage?: string },
|
||||
) {
|
||||
const { taskTitle, baseBranch, firstCommitMessage } = prDefaults;
|
||||
const discardCommit = useChangesDiscardCommitHandlers(gitOps, toast, handleGitOperation);
|
||||
const reset = useChangesResetHandlers(gitOps, handleGitOperation);
|
||||
const pr = useChangesPRHandlers(gitOps, toast, taskTitle, baseBranch);
|
||||
const pr = useChangesPRHandlers(gitOps, toast, taskTitle, baseBranch, firstCommitMessage);
|
||||
return { ...discardCommit, ...reset, ...pr };
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
IconGitPullRequest,
|
||||
IconCloudUpload,
|
||||
IconChevronDown,
|
||||
IconLoader2,
|
||||
} from "@tabler/icons-react";
|
||||
|
||||
import { Button } from "@kandev/ui/button";
|
||||
@@ -141,6 +142,7 @@ type ActionButtonsSectionProps = {
|
||||
onPush: () => void;
|
||||
onForcePush: () => void;
|
||||
isLoading: boolean;
|
||||
loadingOperation: string | null;
|
||||
aheadCount: number;
|
||||
canPush: boolean;
|
||||
canCreatePR: boolean;
|
||||
@@ -152,14 +154,17 @@ export function ActionButtonsSection({
|
||||
onPush,
|
||||
onForcePush,
|
||||
isLoading,
|
||||
loadingOperation,
|
||||
aheadCount,
|
||||
canPush,
|
||||
canCreatePR,
|
||||
existingPrUrl,
|
||||
}: ActionButtonsSectionProps) {
|
||||
const prExists = !!existingPrUrl;
|
||||
const createPrDisabled = !canCreatePR || prExists;
|
||||
const createPrDisabled = !canCreatePR || prExists || isLoading;
|
||||
const pushDisabled = !canPush || isLoading;
|
||||
const isPushing = loadingOperation === "push";
|
||||
const isCreatingPR = loadingOperation === "create_pr";
|
||||
let pushTooltip: string | null = null;
|
||||
if (isLoading) pushTooltip = "A git operation is in progress";
|
||||
else if (!canPush) pushTooltip = "No commits ahead of remote";
|
||||
@@ -176,7 +181,11 @@ export function ActionButtonsSection({
|
||||
onClick={onOpenPRDialog}
|
||||
disabled={createPrDisabled}
|
||||
>
|
||||
<IconGitPullRequest className="h-3 w-3" />
|
||||
{isCreatingPR ? (
|
||||
<IconLoader2 className="h-3 w-3 animate-spin" />
|
||||
) : (
|
||||
<IconGitPullRequest className="h-3 w-3" />
|
||||
)}
|
||||
Create PR
|
||||
</Button>
|
||||
</span>
|
||||
@@ -193,9 +202,13 @@ export function ActionButtonsSection({
|
||||
onClick={onPush}
|
||||
disabled={pushDisabled}
|
||||
>
|
||||
<IconCloudUpload className="h-3 w-3" />
|
||||
{isPushing ? (
|
||||
<IconLoader2 className="h-3 w-3 animate-spin" />
|
||||
) : (
|
||||
<IconCloudUpload className="h-3 w-3" />
|
||||
)}
|
||||
Push
|
||||
{aheadCount > 0 && (
|
||||
{aheadCount > 0 && !isPushing && (
|
||||
<span className="text-muted-foreground">{aheadCount} ahead</span>
|
||||
)}
|
||||
</Button>
|
||||
@@ -234,7 +247,11 @@ type FileListSectionProps = {
|
||||
pendingStageFiles: Set<string>;
|
||||
isLast: boolean;
|
||||
actionLabel: string;
|
||||
isActionLoading?: boolean;
|
||||
onAction: () => void;
|
||||
secondaryActionLabel?: string;
|
||||
isSecondaryActionLoading?: boolean;
|
||||
onSecondaryAction?: () => void;
|
||||
onOpenDiff: (path: string) => void;
|
||||
onEditFile: (path: string) => void;
|
||||
onStage: (path: string) => void;
|
||||
@@ -248,7 +265,11 @@ export function FileListSection({
|
||||
pendingStageFiles,
|
||||
isLast,
|
||||
actionLabel,
|
||||
isActionLoading,
|
||||
onAction,
|
||||
secondaryActionLabel,
|
||||
isSecondaryActionLoading,
|
||||
onSecondaryAction,
|
||||
onOpenDiff,
|
||||
onEditFile,
|
||||
onStage,
|
||||
@@ -277,15 +298,29 @@ export function FileListSection({
|
||||
</ul>
|
||||
)}
|
||||
{files.length > 0 && (
|
||||
<div className="mt-1.5">
|
||||
<div className="mt-1.5 flex items-center gap-1.5">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="h-6 text-[11px] px-2.5 gap-1 cursor-pointer"
|
||||
onClick={onAction}
|
||||
disabled={isActionLoading}
|
||||
>
|
||||
{isActionLoading && <IconLoader2 className="h-3 w-3 animate-spin" />}
|
||||
{actionLabel}
|
||||
</Button>
|
||||
{onSecondaryAction && secondaryActionLabel && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="h-6 text-[11px] px-2.5 gap-1 cursor-pointer"
|
||||
onClick={onSecondaryAction}
|
||||
disabled={isSecondaryActionLoading}
|
||||
>
|
||||
{isSecondaryActionLoading && <IconLoader2 className="h-3 w-3 animate-spin" />}
|
||||
{secondaryActionLabel}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</TimelineSection>
|
||||
|
||||
@@ -214,6 +214,7 @@ type ChangesPanelBodyProps = {
|
||||
totalFileCount: number;
|
||||
aheadCount: number;
|
||||
isLoading: boolean;
|
||||
loadingOperation: string | null;
|
||||
dialogs: ReturnType<typeof useChangesDialogHandlers>;
|
||||
onOpenDiffFile: (path: string) => void;
|
||||
onEditFile: (path: string) => void;
|
||||
@@ -221,6 +222,7 @@ type ChangesPanelBodyProps = {
|
||||
onOpenReview?: () => void;
|
||||
onRevertCommit?: (sha: string) => void;
|
||||
onStageAll: () => void;
|
||||
onUnstageAll: () => void;
|
||||
onStage: (path: string) => Promise<void>;
|
||||
onUnstage: (path: string) => Promise<void>;
|
||||
onPush: () => void;
|
||||
@@ -325,12 +327,14 @@ type TimelineProps = Pick<
|
||||
| "pendingStageFiles"
|
||||
| "aheadCount"
|
||||
| "isLoading"
|
||||
| "loadingOperation"
|
||||
| "dialogs"
|
||||
| "onOpenDiffFile"
|
||||
| "onEditFile"
|
||||
| "onOpenCommitDetail"
|
||||
| "onRevertCommit"
|
||||
| "onStageAll"
|
||||
| "onUnstageAll"
|
||||
| "onStage"
|
||||
| "onUnstage"
|
||||
| "onPush"
|
||||
@@ -340,6 +344,9 @@ type TimelineProps = Pick<
|
||||
function TimelineLocalChanges(props: TimelineProps) {
|
||||
const showStaged = props.hasUnstaged || props.hasStaged;
|
||||
const showCommits = props.hasStaged || props.hasCommits;
|
||||
// Only show bulk-action spinners when no individual files are pending
|
||||
// (per-file ops set pendingStageFiles; bulk ops don't)
|
||||
const isBulkOp = props.pendingStageFiles.size === 0;
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -350,6 +357,7 @@ function TimelineLocalChanges(props: TimelineProps) {
|
||||
pendingStageFiles={props.pendingStageFiles}
|
||||
isLast={!showStaged}
|
||||
actionLabel="Stage all"
|
||||
isActionLoading={isBulkOp && props.loadingOperation === "stage"}
|
||||
onAction={props.onStageAll}
|
||||
onOpenDiff={props.onOpenDiffFile}
|
||||
onEditFile={props.onEditFile}
|
||||
@@ -365,7 +373,11 @@ function TimelineLocalChanges(props: TimelineProps) {
|
||||
pendingStageFiles={props.pendingStageFiles}
|
||||
isLast={!showCommits}
|
||||
actionLabel="Commit"
|
||||
isActionLoading={props.loadingOperation === "commit"}
|
||||
onAction={props.dialogs.handleOpenCommitDialog}
|
||||
secondaryActionLabel="Unstage all"
|
||||
isSecondaryActionLoading={isBulkOp && props.loadingOperation === "unstage"}
|
||||
onSecondaryAction={props.onUnstageAll}
|
||||
onOpenDiff={props.onOpenDiffFile}
|
||||
onEditFile={props.onEditFile}
|
||||
onStage={props.onStage}
|
||||
@@ -388,6 +400,7 @@ function TimelineLocalChanges(props: TimelineProps) {
|
||||
onOpenPRDialog={props.dialogs.handleOpenPRDialog}
|
||||
onPush={props.onPush}
|
||||
isLoading={props.isLoading}
|
||||
loadingOperation={props.loadingOperation}
|
||||
aheadCount={props.aheadCount}
|
||||
canPush={props.canPush}
|
||||
canCreatePR={props.canCreatePR}
|
||||
@@ -511,13 +524,12 @@ const ChangesPanel = memo(function ChangesPanel({
|
||||
const staged = useMemo(() => computeStagedStats(git.stagedFiles), [git.stagedFiles]);
|
||||
|
||||
const gitHandlers = useChangesGitHandlers(git, toast, baseBranch);
|
||||
const dialogs = useChangesDialogHandlers(
|
||||
git,
|
||||
toast,
|
||||
gitHandlers.handleGitOperation,
|
||||
const firstCommitMessage = git.commits?.[0]?.commit_message;
|
||||
const dialogs = useChangesDialogHandlers(git, toast, gitHandlers.handleGitOperation, {
|
||||
taskTitle,
|
||||
baseBranch,
|
||||
);
|
||||
firstCommitMessage,
|
||||
});
|
||||
|
||||
if (isArchived) return <ArchivedPanelPlaceholder />;
|
||||
|
||||
@@ -531,6 +543,7 @@ const ChangesPanel = memo(function ChangesPanel({
|
||||
baseBranchDisplay={baseBranchDisplay}
|
||||
behindCount={git.behind}
|
||||
isLoading={git.isLoading}
|
||||
loadingOperation={git.loadingOperation}
|
||||
onOpenDiffAll={onOpenDiffAll}
|
||||
onOpenReview={onOpenReview}
|
||||
onPull={gitHandlers.handlePull}
|
||||
@@ -556,6 +569,7 @@ const ChangesPanel = memo(function ChangesPanel({
|
||||
totalFileCount={totalFileCount}
|
||||
aheadCount={git.ahead}
|
||||
isLoading={git.isLoading}
|
||||
loadingOperation={git.loadingOperation}
|
||||
dialogs={dialogs}
|
||||
onOpenDiffFile={onOpenDiffFile}
|
||||
onEditFile={onEditFile}
|
||||
@@ -563,8 +577,9 @@ const ChangesPanel = memo(function ChangesPanel({
|
||||
onRevertCommit={gitHandlers.handleRevertCommit}
|
||||
onOpenReview={onOpenReview}
|
||||
onStageAll={git.stageAll}
|
||||
onStage={(path) => git.stage([path]).then(() => undefined)}
|
||||
onUnstage={(path) => git.unstage([path]).then(() => undefined)}
|
||||
onUnstageAll={git.unstageAll}
|
||||
onStage={(path) => git.stageFile([path]).then(() => undefined)}
|
||||
onUnstage={(path) => git.unstageFile([path]).then(() => undefined)}
|
||||
onPush={gitHandlers.handlePush}
|
||||
onForcePush={gitHandlers.handleForcePush}
|
||||
stagedFileCount={staged.stagedFileCount}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useState } from "react";
|
||||
import { IconBrain } from "@tabler/icons-react";
|
||||
import { IconBrain, IconGitMerge, IconX } from "@tabler/icons-react";
|
||||
import { getWebSocketClient } from "@/lib/ws/connection";
|
||||
import { useKeyboardShortcut } from "@/hooks/use-keyboard-shortcut";
|
||||
import { SHORTCUTS } from "@/lib/keyboard/constants";
|
||||
@@ -19,6 +19,8 @@ import {
|
||||
formatPlanCommentsAsMarkdown,
|
||||
} from "@/lib/state/slices/comments/format";
|
||||
import { setChatDraftContent } from "@/lib/local-storage";
|
||||
import { useTaskActions } from "@/hooks/use-task-actions";
|
||||
import { useToast } from "@/components/toast-provider";
|
||||
import type { DiffComment } from "@/lib/diff/types";
|
||||
import type { useChatPanelState } from "./use-chat-panel-state";
|
||||
|
||||
@@ -246,6 +248,48 @@ After completing the implementation, provide a summary of what was done.
|
||||
}, [resolvedSessionId, taskId, handlePlanModeChange, chatInputRef]);
|
||||
}
|
||||
|
||||
function PRMergedBanner({ taskId }: { taskId: string }) {
|
||||
const taskPR = useAppStore((state) => state.taskPRs.byTaskId[taskId]);
|
||||
const [dismissed, setDismissed] = useState(false);
|
||||
const { archiveTaskById } = useTaskActions();
|
||||
const { toast } = useToast();
|
||||
|
||||
const handleArchive = useCallback(async () => {
|
||||
try {
|
||||
await archiveTaskById(taskId);
|
||||
toast({ description: "Task archived" });
|
||||
} catch {
|
||||
toast({ description: "Failed to archive task", variant: "error" });
|
||||
}
|
||||
}, [taskId, archiveTaskById, toast]);
|
||||
|
||||
if (taskPR?.state !== "merged" || dismissed) return null;
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2 mx-1 mb-1 px-3 py-1.5 text-xs rounded-md bg-purple-500/10 text-purple-600 dark:text-purple-400">
|
||||
<IconGitMerge className="h-3.5 w-3.5 shrink-0" />
|
||||
<span className="flex-1">
|
||||
PR #{taskPR.pr_number} has been merged. You can archive this task.
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleArchive}
|
||||
className="underline underline-offset-2 hover:text-purple-700 dark:hover:text-purple-300 cursor-pointer"
|
||||
>
|
||||
Archive
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Dismiss"
|
||||
onClick={() => setDismissed(true)}
|
||||
className="p-0.5 hover:bg-purple-500/10 rounded cursor-pointer"
|
||||
>
|
||||
<IconX className="h-3 w-3" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type ChatInputAreaProps = {
|
||||
chatInputRef: React.RefObject<ChatInputContainerHandle | null>;
|
||||
clarificationKey: number;
|
||||
@@ -321,6 +365,7 @@ export function ChatInputArea({
|
||||
<span className="capitalize">{agentMode} mode</span>
|
||||
</div>
|
||||
)}
|
||||
{taskId && <PRMergedBanner key={taskId} taskId={taskId} />}
|
||||
<ChatInputContainer
|
||||
ref={chatInputRef}
|
||||
key={clarificationKey}
|
||||
|
||||
@@ -191,6 +191,7 @@ export function PRDialog({
|
||||
baseBranch,
|
||||
isGitLoading,
|
||||
taskTitle,
|
||||
firstCommitMessage,
|
||||
onCreatePR,
|
||||
}: {
|
||||
open: boolean;
|
||||
@@ -199,6 +200,7 @@ export function PRDialog({
|
||||
baseBranch: string | undefined;
|
||||
isGitLoading: boolean;
|
||||
taskTitle: string | undefined;
|
||||
firstCommitMessage?: string;
|
||||
onCreatePR: (title: string, body: string, draft: boolean) => void;
|
||||
}) {
|
||||
const [prTitle, setPrTitle] = useState("");
|
||||
@@ -207,7 +209,7 @@ export function PRDialog({
|
||||
|
||||
const handleOpen = (isOpen: boolean) => {
|
||||
if (isOpen) {
|
||||
setPrTitle(taskTitle || "");
|
||||
setPrTitle(firstCommitMessage || taskTitle || "");
|
||||
setPrBody("");
|
||||
}
|
||||
onOpenChange(isOpen);
|
||||
|
||||
@@ -249,6 +249,7 @@ export const SessionMobileTopBar = memo(function SessionMobileTopBar({
|
||||
baseBranch={baseBranch}
|
||||
isGitLoading={isGitLoading}
|
||||
taskTitle={taskTitle}
|
||||
firstCommitMessage={commits[0]?.commit_message}
|
||||
onCreatePR={handleCreatePR}
|
||||
/>
|
||||
</header>
|
||||
|
||||
@@ -594,9 +594,10 @@ export function PassthroughTerminal(props: PassthroughTerminalProps) {
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={terminalRef}
|
||||
className="h-full w-full overflow-hidden p-2 bg-background"
|
||||
style={{ minWidth: MIN_WIDTH, minHeight: MIN_HEIGHT }}
|
||||
/>
|
||||
>
|
||||
<div ref={terminalRef} className="h-full w-full" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -308,7 +308,9 @@ export function ShellTerminal({
|
||||
if (isReadOnlyMode) {
|
||||
return (
|
||||
<div className="h-full w-full bg-transparent relative">
|
||||
<div ref={terminalRef} className="p-1 absolute inset-0" />
|
||||
<div className="p-1 absolute inset-0">
|
||||
<div ref={terminalRef} className="h-full w-full" />
|
||||
</div>
|
||||
{isStopping && (
|
||||
<div className="absolute right-3 top-2 text-xs text-muted-foreground">Stopping…</div>
|
||||
)}
|
||||
@@ -323,5 +325,9 @@ export function ShellTerminal({
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return <div ref={terminalRef} className="h-full p-1 w-full overflow-hidden bg-transparent" />;
|
||||
return (
|
||||
<div className="h-full p-1 w-full overflow-hidden bg-transparent">
|
||||
<div ref={terminalRef} className="h-full w-full" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import { memo, useState } from "react";
|
||||
import { IconLoader2 } from "@tabler/icons-react";
|
||||
import { IconLoader2, IconGitMerge } from "@tabler/icons-react";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { TaskState, TaskSessionState } from "@/lib/types/http";
|
||||
import { TaskItemMenu } from "./task-item-menu";
|
||||
@@ -34,6 +35,7 @@ type TaskItemProps = {
|
||||
isDeleting?: boolean;
|
||||
taskId?: string;
|
||||
primarySessionId?: string | null;
|
||||
prState?: "open" | "closed" | "merged" | null;
|
||||
};
|
||||
|
||||
// Helper to format relative time
|
||||
@@ -59,6 +61,37 @@ function handleTaskItemKeyDown(e: React.KeyboardEvent<HTMLDivElement>, onClick?:
|
||||
onClick?.();
|
||||
}
|
||||
|
||||
function TaskItemTitle({
|
||||
title,
|
||||
description,
|
||||
prState,
|
||||
}: {
|
||||
title: string;
|
||||
description?: string;
|
||||
prState?: "open" | "closed" | "merged" | null;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex min-w-0 flex-1 flex-col">
|
||||
<span className="flex items-center gap-1 line-clamp-1 min-w-0 text-[13px] font-medium text-foreground">
|
||||
{prState === "merged" && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span aria-label="PR merged" className="inline-flex shrink-0">
|
||||
<IconGitMerge className="h-3.5 w-3.5 text-purple-500" />
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>PR merged</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
{title}
|
||||
</span>
|
||||
{description && (
|
||||
<span className="text-[11px] text-muted-foreground/60 truncate">{description}</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TaskItemMeta({
|
||||
effectiveMenuOpen,
|
||||
isRemoteExecutor,
|
||||
@@ -129,6 +162,7 @@ export const TaskItem = memo(function TaskItem({
|
||||
isDeleting,
|
||||
taskId,
|
||||
primarySessionId,
|
||||
prState,
|
||||
}: TaskItemProps) {
|
||||
const [menuOpen, setMenuOpen] = useState(false);
|
||||
|
||||
@@ -162,14 +196,7 @@ export const TaskItem = memo(function TaskItem({
|
||||
/>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex min-w-0 flex-1 flex-col">
|
||||
<span className="line-clamp-1 min-w-0 text-[13px] font-medium text-foreground">
|
||||
{title}
|
||||
</span>
|
||||
{description && (
|
||||
<span className="text-[11px] text-muted-foreground/60 truncate">{description}</span>
|
||||
)}
|
||||
</div>
|
||||
<TaskItemTitle title={title} description={description} prState={prState} />
|
||||
|
||||
{/* Right side: step name + meta, or action buttons on hover */}
|
||||
<div className="relative flex items-center shrink-0">
|
||||
|
||||
@@ -99,6 +99,7 @@ function useSidebarData(workspaceId: string | null) {
|
||||
const snapshots = useAppStore((state) => state.kanbanMulti.snapshots);
|
||||
const isMultiLoading = useAppStore((state) => state.kanbanMulti.isLoading);
|
||||
const repositoriesByWorkspace = useAppStore((state) => state.repositories.itemsByWorkspaceId);
|
||||
const taskPRsByTaskId = useAppStore((state) => state.taskPRs.byTaskId);
|
||||
const archivedState = useArchivedTaskState();
|
||||
|
||||
const selectedTaskId = useMemo(() => {
|
||||
@@ -148,6 +149,7 @@ function useSidebarData(workspaceId: string | null) {
|
||||
primarySessionId: task.primarySessionId ?? null,
|
||||
updatedAt: sessionInfo.updatedAt ?? task.updatedAt,
|
||||
isArchived: false as boolean,
|
||||
prState: (taskPRsByTaskId[task.id]?.state as "open" | "closed" | "merged") ?? null,
|
||||
};
|
||||
});
|
||||
if (
|
||||
@@ -170,6 +172,9 @@ function useSidebarData(workspaceId: string | null) {
|
||||
primarySessionId: null,
|
||||
updatedAt: archivedState.archivedTaskUpdatedAt,
|
||||
isArchived: true,
|
||||
prState:
|
||||
(taskPRsByTaskId[archivedState.archivedTaskId]?.state as "open" | "closed" | "merged") ??
|
||||
null,
|
||||
});
|
||||
}
|
||||
return items;
|
||||
@@ -179,6 +184,7 @@ function useSidebarData(workspaceId: string | null) {
|
||||
workspaceId,
|
||||
sessionsByTaskId,
|
||||
gitStatusBySessionId,
|
||||
taskPRsByTaskId,
|
||||
archivedState,
|
||||
]);
|
||||
|
||||
|
||||
@@ -36,6 +36,7 @@ type TaskSwitcherItem = {
|
||||
updatedAt?: string;
|
||||
isArchived?: boolean;
|
||||
primarySessionId?: string | null;
|
||||
prState?: "open" | "closed" | "merged" | null;
|
||||
};
|
||||
|
||||
type TaskSwitcherProps = {
|
||||
@@ -149,6 +150,7 @@ function TaskSwitcherSection({
|
||||
taskId={task.id}
|
||||
primarySessionId={task.primarySessionId ?? null}
|
||||
updatedAt={task.updatedAt}
|
||||
prState={task.prState}
|
||||
onClick={() => onSelectTask(task.id)}
|
||||
onRename={onRenameTask ? () => onRenameTask(task.id, task.title) : undefined}
|
||||
onArchive={onArchiveTask ? () => onArchiveTask(task.id) : undefined}
|
||||
@@ -193,16 +195,6 @@ export const TaskSwitcher = memo(function TaskSwitcher({
|
||||
else backlog.push(task);
|
||||
}
|
||||
|
||||
const byRecent = (a: TaskSwitcherItem, b: TaskSwitcherItem) => {
|
||||
const ta = a.updatedAt ?? "";
|
||||
const tb = b.updatedAt ?? "";
|
||||
if (ta !== tb) return tb.localeCompare(ta);
|
||||
return a.title.localeCompare(b.title);
|
||||
};
|
||||
review.sort(byRecent);
|
||||
inProgress.sort(byRecent);
|
||||
backlog.sort(byRecent);
|
||||
|
||||
return [
|
||||
{ label: "Review", tasks: review },
|
||||
{ label: "In Progress", tasks: inProgress },
|
||||
|
||||
@@ -131,7 +131,7 @@ export const backendFixture = base.extend<object, { backend: BackendContext }>({
|
||||
KANDEV_DATA_DIR: dataDir,
|
||||
KANDEV_SERVER_PORT: String(backendPort),
|
||||
KANDEV_DATABASE_PATH: dbPath,
|
||||
KANDEV_MOCK_AGENT: "true",
|
||||
KANDEV_MOCK_AGENT: "only",
|
||||
KANDEV_MOCK_GITHUB: "true",
|
||||
KANDEV_DOCKER_ENABLED: "false",
|
||||
KANDEV_WORKTREE_ENABLED: "false",
|
||||
|
||||
@@ -44,6 +44,7 @@ export type SessionGit = {
|
||||
|
||||
// Operation state
|
||||
isLoading: boolean;
|
||||
loadingOperation: string | null;
|
||||
pendingStageFiles: Set<string>;
|
||||
|
||||
// Actions
|
||||
@@ -54,8 +55,11 @@ export type SessionGit = {
|
||||
abort: (operation: "merge" | "rebase") => Promise<GitOperationResult>;
|
||||
commit: (message: string, stageAll?: boolean, amend?: boolean) => Promise<GitOperationResult>;
|
||||
stage: (paths?: string[]) => Promise<GitOperationResult>;
|
||||
stageFile: (paths: string[]) => Promise<GitOperationResult>;
|
||||
stageAll: () => Promise<GitOperationResult>;
|
||||
unstage: (paths?: string[]) => Promise<GitOperationResult>;
|
||||
unstageFile: (paths: string[]) => Promise<GitOperationResult>;
|
||||
unstageAll: () => Promise<GitOperationResult>;
|
||||
discard: (paths?: string[]) => Promise<GitOperationResult>;
|
||||
revertCommit: (commitSHA: string) => Promise<GitOperationResult>;
|
||||
renameBranch: (newName: string) => Promise<GitOperationResult>;
|
||||
@@ -96,6 +100,41 @@ export function useSessionGit(sessionId: string | null | undefined): SessionGit
|
||||
const hasCommits = commits.length > 0;
|
||||
|
||||
const stageAll = useCallback(async () => gitOps.stage(), [gitOps]);
|
||||
const unstageAll = useCallback(async () => gitOps.unstage(), [gitOps]);
|
||||
|
||||
const stageFile = useCallback(
|
||||
async (paths: string[]) => {
|
||||
for (const p of paths) setPendingStageFiles((prev) => new Set(prev).add(p));
|
||||
try {
|
||||
return await gitOps.stage(paths);
|
||||
} catch (err) {
|
||||
setPendingStageFiles((prev) => {
|
||||
const next = new Set(prev);
|
||||
for (const p of paths) next.delete(p);
|
||||
return next;
|
||||
});
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
[gitOps],
|
||||
);
|
||||
|
||||
const unstageFile = useCallback(
|
||||
async (paths: string[]) => {
|
||||
for (const p of paths) setPendingStageFiles((prev) => new Set(prev).add(p));
|
||||
try {
|
||||
return await gitOps.unstage(paths);
|
||||
} catch (err) {
|
||||
setPendingStageFiles((prev) => {
|
||||
const next = new Set(prev);
|
||||
for (const p of paths) next.delete(p);
|
||||
return next;
|
||||
});
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
[gitOps],
|
||||
);
|
||||
|
||||
return {
|
||||
branch: gitStatus?.branch ?? null,
|
||||
@@ -122,6 +161,7 @@ export function useSessionGit(sessionId: string | null | undefined): SessionGit
|
||||
canCreatePR: hasCommits,
|
||||
|
||||
isLoading: gitOps.isLoading,
|
||||
loadingOperation: gitOps.loadingOperation,
|
||||
pendingStageFiles,
|
||||
|
||||
pull: gitOps.pull,
|
||||
@@ -131,8 +171,11 @@ export function useSessionGit(sessionId: string | null | undefined): SessionGit
|
||||
abort: gitOps.abort,
|
||||
commit: gitOps.commit,
|
||||
stage: gitOps.stage,
|
||||
stageFile,
|
||||
stageAll,
|
||||
unstage: gitOps.unstage,
|
||||
unstageFile,
|
||||
unstageAll,
|
||||
discard: gitOps.discard,
|
||||
revertCommit: gitOps.revertCommit,
|
||||
renameBranch: gitOps.renameBranch,
|
||||
|
||||
@@ -43,6 +43,7 @@ interface UseGitOperationsReturn {
|
||||
|
||||
// State
|
||||
isLoading: boolean;
|
||||
loadingOperation: string | null;
|
||||
error: string | null;
|
||||
lastResult: GitOperationResult | null;
|
||||
}
|
||||
@@ -128,6 +129,7 @@ function buildGitOperationCallbacks(executeOperation: ExecuteOperation) {
|
||||
|
||||
export function useGitOperations(sessionId: string | null): UseGitOperationsReturn {
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [loadingOperation, setLoadingOperation] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [lastResult, setLastResult] = useState<GitOperationResult | null>(null);
|
||||
|
||||
@@ -141,6 +143,7 @@ export function useGitOperations(sessionId: string | null): UseGitOperationsRetu
|
||||
if (!client) throw new Error("WebSocket not connected");
|
||||
|
||||
setIsLoading(true);
|
||||
setLoadingOperation(action.replace("worktree.", ""));
|
||||
setError(null);
|
||||
|
||||
const timeout = action === "worktree.create_pr" ? 120000 : 60000;
|
||||
@@ -159,6 +162,7 @@ export function useGitOperations(sessionId: string | null): UseGitOperationsRetu
|
||||
throw e;
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
setLoadingOperation(null);
|
||||
}
|
||||
},
|
||||
[sessionId],
|
||||
@@ -169,6 +173,7 @@ export function useGitOperations(sessionId: string | null): UseGitOperationsRetu
|
||||
return {
|
||||
...ops,
|
||||
isLoading,
|
||||
loadingOperation,
|
||||
error,
|
||||
lastResult,
|
||||
};
|
||||
|
||||
@@ -99,6 +99,8 @@
|
||||
"react-resizable-panels": "^4.4.1",
|
||||
"react-virtuoso": "^4.18.1",
|
||||
"recharts": "2.15.4",
|
||||
"rehype-raw": "^7.0.0",
|
||||
"rehype-sanitize": "^6.0.0",
|
||||
"remark-breaks": "^4.0.0",
|
||||
"remark-gemoji": "^8.0.0",
|
||||
"remark-gfm": "^4.0.1",
|
||||
|
||||
@@ -101,7 +101,7 @@ Each Playwright worker spawns an isolated `kandev` backend and a dedicated `next
|
||||
- `KANDEV_DATA_DIR` — temp data directory
|
||||
- `KANDEV_SERVER_PORT` — unique port per worker (`18080 + workerIndex`)
|
||||
- `KANDEV_DATABASE_PATH` — temp SQLite path
|
||||
- `KANDEV_MOCK_AGENT=true` — only loads mock agent, skips agent discovery
|
||||
- `KANDEV_MOCK_AGENT=only` — only loads mock agent, skips agent discovery (use `"true"` in dev mode to enable mock alongside all agents)
|
||||
- `KANDEV_MOCK_GITHUB=true` — uses in-memory MockClient instead of real GitHub API
|
||||
- `KANDEV_DOCKER_ENABLED=false` — no Docker
|
||||
- `KANDEV_WORKTREE_ENABLED=false` — no worktrees
|
||||
|
||||
Reference in New Issue
Block a user